iZOOlogic

Fetches and manages incidents from iZOOlogic, enabling automated ingestion, incident creation, and advanced filtering for brand protection and threat management.

Network Security · iZOOlogic

Details

IDiZOOlogic
ProvideriZOOlogic
CategoryNetwork Security
From Version8.2.0
Docker Imagedemisto/fastapi:0.125.0.10158186

README

Fetches and manages incidents from iZOOlogic, enabling automated ingestion, incident creation, and advanced filtering for brand protection and threat management.

Configure iZOOlogic in Cortex

Parameter Description Required
Server URL The iZOOlogic API server URL. True
API Key The API key provided by iZOOlogic for authentication. True
Secret Key The secret key corresponding to the API key. True
Trust any certificate (not secure) Whether to trust any certificate (not secure). False
Use system proxy settings Whether to use the system proxy settings. False
Fetch incidents Whether to fetch incidents from iZOOlogic. False
Fetch incident types A comma-separated list of incident types to fetch from iZOOlogic. True
Maximum incidents per fetch per type The maximum number of incidents to fetch per type per fetch cycle. True

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.

izoologic-get-events


Gets events from iZOOlogic. Use this command for development and debugging only, as it may produce duplicate events, exceed API rate limits, or disrupt the fetch mechanism.

Base Command

izoologic-get-events

Input

Argument Name Description Required
limit The maximum number of events to return per type. Default is 50. Optional
start_time The time to filter events detected at or after. Supports ISO 8601 format or relative time expressions (e.g., “3 days ago”, “2024-01-01T00:00:00Z”). Optional
end_time The time to filter events detected at or before. Supports ISO 8601 format or relative time expressions (e.g., “now”, “2024-01-01T00:00:00Z”). Optional
event_type The event types to filter by, as a comma-separated list. If not specified, the command uses the types configured in the integration parameters. Possible values are: brand abuse, phishing, malware, pharming, smishing, vishing, mobile apps, social media, other, email. Optional
should_push_events The flag that indicates whether to push events to Cortex XSIAM. Pushing events is supported on Cortex XSIAM only. When set to false, or on non-Cortex XSIAM platforms, events are displayed without being pushed. Possible values are: true, false. Default is false. Optional

Context Output

Path Type Description
iZOOlogic.Incident.incidentID String The unique identifier of the incident.
iZOOlogic.Incident.incidentType String The type and subtype of the incident.
iZOOlogic.Incident.subIncidentType String The subtype of the incident.
iZOOlogic.Incident.detectionDate String The detection date of the incident as a Unix timestamp.
iZOOlogic.Incident.url String The URL associated with the incident.
iZOOlogic.Incident.status String The current status of the incident.
iZOOlogic.Incident.statusCode Number The numeric status code of the incident.
iZOOlogic.Incident.brand String The brand associated with the incident.
iZOOlogic.Incident.threatType String The threat level of the incident.
iZOOlogic.Incident.createdOn String The creation date of the incident as a Unix timestamp.
iZOOlogic.Incident.closedOn String The closing date of the incident as a Unix timestamp.
iZOOlogic.Incident.detectedBy String The entity that detected the incident.

Command example

!izoologic-get-events limit=3

Human Readable Output

iZOOlogic Events

Incident ID Incident Type Sub Incident Type Brand Url Status Status Code Threat Type Detection Date Created On Closed On Detected By
uVJxla1s1 Brand Abuse - Fake Website Fake Website TVS Motor https://tvsmotor.com.mt Waiting 17 Substantial Threat 1760941801 1769509374   Reported By iZOOLogic
1JrJzZBip Phishing   TVS Credit https://tvs-credit.dev.veefin.in Closed 16 High Threat 1769792260 1769792260 1770180062 Reported By iZOOLogic
KIks8sE3U Social Media - Facebook Facebook TVS King https://www.facebook.com/ads/library/?id=917334661007536 Waiting 17 Substantial Threat 1769626014 1769626014   Reported By iZOOLogic

izoologic-incident-create


Creates a new security incident in iZOOlogic.

Base Command

izoologic-incident-create

Input

Argument Name Description Required
incident_url The URL, email, or target of the security incident (max 1000 characters). Required
incident_type The type of incident. Possible values are: brand abuse, phishing, malware, pharming, smishing, vishing, mobile apps, social media, other, email, executive. Required
brand_code The brand identifier associated with the incident. Required
threat_type The threat level. Possible values are: low threat, moderate threat, substantial threat, high threat, critical threat, redirect to whitelist. Default is moderate threat. Optional
case_type The preferred case type for processing. All new incidents are initially created as “Reported Incident” and may be reclassified during review. Possible values are: incident, brand abuse monitoring, domain monitoring, social media monitoring, mobile app monitoring, executive monitoring. Default is incident. Optional
comment The comments about the incident (max 2500 characters). Optional
executive_name The executive name. Required for executive-related incidents (max 2500 characters). Optional
client_code The client identifier for validation and access control. Optional

Context Output

Path Type Description
iZOOlogic.Incident.reportedIncidentId String The unique identifier for the created incident case.
iZOOlogic.Incident.statusCode Number The numeric status code (1 = under review).
iZOOlogic.Incident.statusDescription String The human-readable status description.
iZOOlogic.Incident.caseType Number The case type code (9 = reported incident).
iZOOlogic.Incident.caseTypeDescription String The human-readable case type description.

Command example

!izoologic-incident-create incident_url="https://test-malicious-site.example.com" incident_type="phishing" brand_code="QnjggfvwlW"

Human Readable Output

iZOOlogic - New Incident Created

Reported Incident Id Status Code Status Description Case Type Case Type Description
ycB2E7gPQ 1 Under Review 9 Reported Incident

izoologic-incident-fetch


Fetches incidents from iZOOlogic based on specified filters including date range, brand, incident type, and other criteria.

Base Command

izoologic-incident-fetch

Input

Argument Name Description Required
from_date The start date for filtering incidents. Supports ISO 8601 format or relative time expressions (e.g., “1 day ago”, “2024-01-01T00:00:00Z”). Maximum date range is 31 days. Default is 1 day ago. Optional
to_date The end date for filtering incidents. Supports ISO 8601 format or relative time expressions (e.g., “now”, “2024-01-01T00:00:00Z”). Maximum date range is 31 days. Default is now. Optional
incident_type The type of incident to filter by. Possible values are: brand abuse, phishing, malware, pharming, smishing, vishing, mobile apps, social media, other, email, executive. Optional
threat_type The threat level to filter by. Possible values are: low threat, moderate threat, substantial threat, high threat, critical threat, redirect to whitelist. Optional
brand_code The brand identifier to filter incidents by. Optional
executive_name The executive name for filtering executive-related incidents (max 100 characters). Optional
client_ref_id The client reference ID for specific incident lookup. Optional
client_code The client identifier for filtering incidents. Optional

Context Output

Path Type Description
iZOOlogic.Incident.incidentID String The unique identifier of the incident.
iZOOlogic.Incident.incidentType String The type and subtype of the incident.
iZOOlogic.Incident.subIncidentType String The subtype of the incident.
iZOOlogic.Incident.detectionDate String The detection date of the incident as a Unix timestamp (e.g., 1704067200).
iZOOlogic.Incident.url String The URL associated with the incident.
iZOOlogic.Incident.status String The current status of the incident.
iZOOlogic.Incident.statusCode Number The numeric status code of the incident.
iZOOlogic.Incident.brand String The brand associated with the incident.
iZOOlogic.Incident.threatType String The threat level of the incident.
iZOOlogic.Incident.createdOn String The creation date of the incident as a Unix timestamp (e.g., 1704067200).
iZOOlogic.Incident.closedOn String The closing date of the incident as a Unix timestamp (e.g., 1704067200).
iZOOlogic.Incident.detectedBy String The entity that detected the incident.

Command example

!izoologic-incident-fetch from_date="1 day ago" incident_type="phishing"

Human Readable Output

iZOOlogic Incidents

Incident ID Incident Type Brand Url Status Status Code Threat Type Detection Date Created On Detected By
abc123 Phishing ExampleBrand https://example.com Active 1 High Threat 1700000000 1700000200 Reported By iZOOLogic

Configuration parameters

  • url — Server URL (required)
  • api_key — (required)
  • secret_key — (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • isFetchEvents
  • events_types_filter — Fetch incident types (required)
  • max_fetch — Maximum incidents per fetch per type (required)

Commands (3)

  • izoologic-get-events

    Gets events from iZOOlogic. Use this command for development and debugging only, as it may produce duplicate events, exceed API rate limits, or disrupt the fetch mechanism.

  • izoologic-incident-create

    Creates a new security incident in iZOOlogic.

  • izoologic-incident-fetch

    Fetches incidents from iZOOlogic based on specified filters including date range, brand, incident type, and other criteria.

import asyncio
import json
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest
import demistomock as demisto
from CommonServerPython import *  # noqa
from pytest_mock import MockerFixture

from iZOOlogic import (
    Client,
    ApiCodes,
    COMMAND_MAP,
    IZOOlogicAuthHandler,
    _validate_api_response,
    date_to_unix_timestamp,
    get_current_unix_timestamp,
    snap_to_day_boundary_utc,
    parse_date,
    enrich_events,
    create_events,
    filter_by_ids,
    validate_date_range,
    resolve_type_codes,
    parse_integration_params,
    _fetch_all_pages,
    _is_in_range,
    _resolve_fetch_window,
    _compute_new_state,
    _fetch_for_type,
    _resolve_code_by_name,
    _validate_incident_creation_args,
    test_module as izoologic_test_module,
    get_events_command,
    fetch_events_command,
    create_incident_command,
    search_incidents_command,
    main,
)


# region Test Data Loading
TEST_DATA_DIR = Path(__file__).parent / "test_data"


def load_test_data(filename: str) -> dict:
    """Load test data from a JSON file in the test_data directory."""
    with open(TEST_DATA_DIR / filename) as f:
        return json.load(f)


# endregion

# region Fixtures


@pytest.fixture(autouse=True)
def mock_support_multithreading(mocker: MockerFixture):
    """ContentClient calls support_multithreading() on init — mock it."""
    mocker.patch("ContentClientApiModule.support_multithreading")


@pytest.fixture
def events_result() -> dict:
    """The 'result' object from the API response."""
    return load_test_data("events_response.json")["result"]


@pytest.fixture
def events_result_with_pagination() -> dict:
    """The 'result' object with pagination token."""
    return load_test_data("events_response_with_pagination.json")["result"]


@pytest.fixture
def empty_result() -> dict:
    """The 'result' object with no events."""
    return load_test_data("empty_response.json")["result"]


@pytest.fixture
def incidents_result() -> dict:
    """The 'result' object from the API response (same structure as events)."""
    return load_test_data("events_response.json")["result"]


@pytest.fixture
def mock_client(mocker: MockerFixture) -> Client:
    """Create a mock Client with auth handler's _authenticate mocked."""
    client = Client(
        base_url="https://api.test.izoologic.com",
        api_key="test-api-key",
        secret_key="test-secret-key",
        verify=False,
        proxy=False,
    )
    # Mock the auth handler's _authenticate to avoid real API calls
    mocker.patch.object(client._auth_handler, "_authenticate", new_callable=AsyncMock)
    return client


@pytest.fixture
def valid_params() -> dict:
    return {
        "url": "https://api.izoologic.com/",
        "api_key": {"password": "test-key"},
        "secret_key": {"password": "test-secret"},
        "events_types_filter": ["phishing", "malware"],
        "max_fetch": "5000",
    }


# endregion

# region Auth Handler Tests


class TestIZOOlogicAuthHandler:
    """Tests for the IZOOlogicAuthHandler two-step token auth."""

    def test_initial_state(self):
        """Verify handler initializes with no token, not authenticating, and has a lock."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        assert handler._token is None
        assert handler._authenticating is False
        assert hasattr(handler, "_auth_lock")

    def test_on_request_authenticates_when_no_token(self):
        """on_request should call _authenticate when no token is set."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        mock_client = AsyncMock()
        mock_request = AsyncMock()
        mock_request.headers = {}

        handler._authenticate = AsyncMock()
        handler._authenticate.side_effect = lambda client: setattr(handler, "_token", "new-token")

        asyncio.get_event_loop().run_until_complete(handler.on_request(mock_client, mock_request))

        handler._authenticate.assert_called_once_with(mock_client)
        assert mock_request.headers["Authorization"] == "Bearer new-token"

    def test_on_request_skips_auth_when_token_exists(self):
        """on_request should skip _authenticate when token is already set."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        handler._token = "existing-token"
        mock_client = AsyncMock()
        mock_request = AsyncMock()
        mock_request.headers = {}

        handler._authenticate = AsyncMock()

        asyncio.get_event_loop().run_until_complete(handler.on_request(mock_client, mock_request))

        handler._authenticate.assert_not_called()
        assert mock_request.headers["Authorization"] == "Bearer existing-token"

    def test_on_request_skips_during_authentication(self):
        """on_request should not add auth header when _authenticating is True (prevents recursion)."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        handler._authenticating = True
        mock_client = AsyncMock()
        mock_request = AsyncMock()
        mock_request.headers = {}

        asyncio.get_event_loop().run_until_complete(handler.on_request(mock_client, mock_request))

        assert "Authorization" not in mock_request.headers

    def test_on_auth_failure_re_authenticates(self):
        """on_auth_failure should clear token, call _authenticate, and return True to retry."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        handler._token = "expired-token"
        mock_client = AsyncMock()
        mock_response = AsyncMock()

        handler._authenticate = AsyncMock()

        result = asyncio.get_event_loop().run_until_complete(handler.on_auth_failure(mock_client, mock_response))

        handler._authenticate.assert_called_once_with(mock_client)
        assert result is True

    def test_authenticate_skips_when_token_exists(self):
        """_authenticate should skip API call when token is already set (double-check pattern)."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        handler._token = "existing-token"
        mock_client = AsyncMock()
        mock_client._request = AsyncMock()

        asyncio.get_event_loop().run_until_complete(handler._authenticate(mock_client))

        mock_client._request.assert_not_called()
        assert handler._token == "existing-token"

    def test_authenticate_success(self):
        """_authenticate should store the token on successful API response."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        mock_client = AsyncMock()

        mock_raw_response = MagicMock()
        mock_raw_response.json.return_value = {
            "success": True,
            "result": {"accessToken": "test-token-123"},
            "message": "",
            "errorCode": "",
        }
        mock_client._request = AsyncMock(return_value=mock_raw_response)

        asyncio.get_event_loop().run_until_complete(handler._authenticate(mock_client))

        assert handler._token == "test-token-123"
        assert handler._authenticating is False

    def test_authenticate_api_error(self):
        """_authenticate should raise DemistoException on API error response."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        mock_client = AsyncMock()

        mock_raw_response = MagicMock()
        mock_raw_response.json.return_value = {
            "success": False,
            "result": None,
            "message": "Invalid credentials",
            "errorCode": "AUTH_FAILED",
        }
        mock_client._request = AsyncMock(return_value=mock_raw_response)

        with pytest.raises(DemistoException, match="Authentication failed"):
            asyncio.get_event_loop().run_until_complete(handler._authenticate(mock_client))

        assert handler._authenticating is False

    def test_authenticate_no_token_in_response(self):
        """_authenticate should raise DemistoException when no token is returned."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        mock_client = AsyncMock()

        mock_raw_response = MagicMock()
        mock_raw_response.json.return_value = {
            "success": True,
            "result": {},
            "message": "",
            "errorCode": "",
        }
        mock_client._request = AsyncMock(return_value=mock_raw_response)

        with pytest.raises(DemistoException, match="No token received"):
            asyncio.get_event_loop().run_until_complete(handler._authenticate(mock_client))

        assert handler._authenticating is False

    def test_authenticating_flag_reset_on_exception(self):
        """_authenticating flag should be reset even if _request raises."""
        handler = IZOOlogicAuthHandler(api_key="key", secret_key="secret")
        mock_client = AsyncMock()
        mock_client._request = AsyncMock(side_effect=Exception("Network error"))

        with pytest.raises(Exception, match="Network error"):
            asyncio.get_event_loop().run_until_complete(handler._authenticate(mock_client))

        assert handler._authenticating is False


# endregion

# region Date Helper Tests


class TestDateHelpers:
    @pytest.mark.parametrize(
        "date_input, expected",
        [
            ("2024-01-01T00:00:00Z", "1704067200"),
            ("2023-06-15T12:00:00Z", "1686830400"),
        ],
    )
    def test_date_to_unix_timestamp_iso(self, date_input: str, expected: str):
        assert date_to_unix_timestamp(date_input) == expected

    def test_date_to_unix_timestamp_relative(self):
        result = date_to_unix_timestamp("1 hour ago")
        assert result.isdigit()

    def test_parse_date_valid(self):
        result = parse_date("2024-01-01T00:00:00Z")
        assert result.year == 2024

    def test_parse_date_invalid_raises(self):
        with pytest.raises(ValueError):
            parse_date("not-a-date")

    def test_get_current_unix_timestamp(self):
        result = get_current_unix_timestamp()
        assert result.isdigit()


# endregion

# region Snap to Day Boundary Tests


class TestSnapToDayBoundaryUtc:
    @pytest.mark.parametrize(
        "input_ts, boundary, expected",
        [
            # Start boundary (midnight)
            ("1738063023", "start", "1738022400"),  # 2025-01-28T11:37:03Z -> 00:00:00
            ("1738022400", "start", "1738022400"),  # Already at midnight
            ("1704153599", "start", "1704067200"),  # 2024-01-01T23:59:59Z -> 00:00:00
            ("1704067201", "start", "1704067200"),  # 2024-01-01T00:00:01Z -> 00:00:00
            # End boundary (23:59:59)
            ("1704067200", "end", "1704153599"),  # 2024-01-01T00:00:00Z -> 23:59:59
            ("1704153599", "end", "1704153599"),  # Already at 23:59:59
            ("1704100000", "end", "1704153599"),  # Mid-day -> 23:59:59
        ],
    )
    def test_snap(self, input_ts: str, boundary: str, expected: str):
        assert snap_to_day_boundary_utc(input_ts, boundary) == expected


# endregion

# region Add Time To Events / Create Events Tests


class TestEnrichEvents:
    def test_adds_time_and_source_log_type(self):
        events = [{"incidentID": "abc", "incidentType": "Phishing", "createdOn": "100"}]
        enrich_events(events)
        assert events[0]["_time"] == "1970-01-01T00:01:40Z"
        assert events[0]["source_log_type"] == "Phishing"

    def test_missing_created_on(self):
        # When createdOn is missing, _time falls back to the current UTC time
        # so that every event is guaranteed to have a _time value.
        events = [{"incidentID": "1"}]
        enrich_events(events)
        assert events[0]["_time"]  # unconditionally assigned
        # Verify the fallback _time matches the expected ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ).
        datetime.strptime(events[0]["_time"], "%Y-%m-%dT%H:%M:%SZ")
        assert events[0]["source_log_type"] == "Unknown"

    def test_empty_list(self):
        events: list[dict] = []
        enrich_events(events)
        assert events == []

    def test_multiple_events(self):
        events = [
            {"incidentID": "1", "incidentType": "Phishing", "createdOn": "100"},
            {"incidentID": "2", "incidentType": "Malware", "createdOn": "200"},
        ]
        enrich_events(events)
        assert events[0]["_time"] == "1970-01-01T00:01:40Z"
        assert events[0]["source_log_type"] == "Phishing"
        assert events[1]["_time"] == "1970-01-01T00:03:20Z"
        assert events[1]["source_log_type"] == "Malware"


class TestCreateEvents:
    def test_create_events_calls_send_events_to_xsiam(self, mocker: MockerFixture):
        mock_send = mocker.patch("iZOOlogic.send_events_to_xsiam")
        # Events are normalized by the caller (add_time_to_events) before create_events sends them.
        events = [{"incidentID": "abc", "incidentType": "Phishing", "createdOn": "100"}]
        enrich_events(events)
        create_events(events)
        mock_send.assert_called_once()
        sent_events = mock_send.call_args[1]["events"]
        assert len(sent_events) == 1
        assert sent_events[0]["_time"] == "1970-01-01T00:01:40Z"
        assert sent_events[0]["source_log_type"] == "Phishing"


# endregion

# region Filter By IDs Tests


class TestFilterByIds:
    @pytest.mark.parametrize(
        "raw, ids_to_skip, expected_count",
        [
            ([{"incidentID": "1"}, {"incidentID": "2"}], ["3"], 2),  # No match
            ([{"incidentID": "1"}, {"incidentID": "2"}], ["1"], 1),  # One match
            ([{"incidentID": "1"}, {"incidentID": "2"}], ["1", "2"], 0),  # All match
            ([{"incidentID": "1"}], [], 1),  # Empty skip list
            ([], ["1"], 0),  # Empty incidents
        ],
    )
    def test_filter(self, raw: list, ids_to_skip: list, expected_count: int):
        assert len(filter_by_ids(raw, ids_to_skip)) == expected_count


# endregion

# region Validate API Response Tests


class TestValidateApiResponse:
    def test_success_response(self):
        """Successful response returns the 'result' object."""
        response = load_test_data("events_response.json")
        result = _validate_api_response(response)
        assert "incidents" in result
        assert len(result["incidents"]) == 3

    def test_no_data_found_returns_empty(self):
        """Known 'no data found' error code returns empty dict (not an error)."""
        response = {"success": False, "errorCode": "iZOO2011", "message": "No data found"}
        result = _validate_api_response(response)
        assert result == {}

    def test_real_api_error_raises(self):
        """Unknown API error raises DemistoException."""
        response = {"success": False, "errorCode": "iZOO5000", "message": "Server error"}
        with pytest.raises(DemistoException, match="API error: Server error"):
            _validate_api_response(response)

    def test_missing_success_key_treated_as_success(self):
        """Response without 'success' key defaults to True."""
        response = {"result": {"incidents": []}}
        result = _validate_api_response(response)
        assert result == {"incidents": []}


# endregion

# region Validate Date Range Tests


class TestValidateDateRange:
    @pytest.mark.parametrize(
        "days_offset, should_raise, match",
        [
            (1, False, None),  # 1 day — valid
            (31, False, None),  # 31 days — valid (boundary)
            (32, True, "Date range exceeds"),  # 32 days — exceeds max
        ],
    )
    def test_max_range(self, days_offset: int, should_raise: bool, match: str | None):
        from_ts = "1700000000"
        to_ts = str(int(from_ts) + days_offset * 86400)
        if should_raise:
            with pytest.raises(DemistoException, match=match):
                validate_date_range(from_ts, to_ts)
        else:
            validate_date_range(from_ts, to_ts)

    def test_inverted_date_range_raises(self):
        """to_date on an earlier day than from_date should raise."""
        from_ts = "1700100000"  # 2023-11-16
        to_ts = "1700000000"  # 2023-11-15
        with pytest.raises(DemistoException, match="is before"):
            validate_date_range(from_ts, to_ts)

    def test_same_day_does_not_raise(self):
        """Same-day range (to_date == from_date after midnight snap) should not raise."""
        from_ts = "1700092800"  # 2023-11-16T00:00:00Z
        to_ts = "1700100000"  # 2023-11-16T02:00:00Z
        validate_date_range(from_ts, to_ts)  # Should not raise


# endregion

# region Resolve Type Codes Tests


class TestResolveTypeCodes:
    @pytest.mark.parametrize(
        "type_names, expected_codes",
        [
            (["phishing"], [2]),
            (["phishing", "malware"], [2, 3]),
            (["PHISHING"], [2]),  # Case-insensitive
            ([" phishing "], [2]),  # Whitespace trimmed
            (["brand abuse", "email"], [1, 23]),
        ],
    )
    def test_valid_types(self, type_names: list[str], expected_codes: list[int]):
        assert resolve_type_codes(type_names) == expected_codes

    @pytest.mark.parametrize(
        "type_names",
        [
            (["invalid_type"]),
            (["phishing", "nonexistent"]),
        ],
    )
    def test_invalid_type_raises(self, type_names: list[str]):
        with pytest.raises(DemistoException, match="Invalid event type"):
            resolve_type_codes(type_names)


# endregion

# region Parse Integration Params Tests


class TestParseIntegrationParams:
    def test_valid_params(self, valid_params: dict):
        config = parse_integration_params(valid_params)
        assert config["base_url"] == "https://api.izoologic.com"
        assert config["event_type_codes"] == [2, 3]
        assert config["max_fetch"] == 5000

    @pytest.mark.parametrize(
        "override, error_match",
        [
            ({"url": ""}, "Server URL is required"),
            ({"api_key": {"password": ""}}, "API Key is required"),
            ({"secret_key": {"password": ""}}, "Secret Key is required"),
            ({"max_fetch": "-1"}, "Invalid max_fetch value"),
        ],
    )
    def test_invalid_params(self, valid_params: dict, override: dict, error_match: str):
        with pytest.raises(DemistoException, match=error_match):
            parse_integration_params({**valid_params, **override})

    def test_no_filter_defaults_to_all(self, valid_params: dict):
        del valid_params["events_types_filter"]
        config = parse_integration_params(valid_params)
        assert len(config["event_type_codes"]) == 11

    def test_trailing_slash_stripped(self, valid_params: dict):
        valid_params["url"] = "https://api.izoologic.com///"
        config = parse_integration_params(valid_params)
        assert config["base_url"] == "https://api.izoologic.com"

    def test_verify_and_proxy_defaults(self, valid_params: dict):
        config = parse_integration_params(valid_params)
        assert config["verify"] is True  # insecure not set -> verify=True
        assert config["proxy"] is False

    def test_insecure_flag(self, valid_params: dict):
        valid_params["insecure"] = True
        config = parse_integration_params(valid_params)
        assert config["verify"] is False


# endregion

# region Client Tests


class TestClient:
    def test_fetch_events_page_full_body(self, mocker: MockerFixture, mock_client: Client):
        """Test that fetch_events_page sends correct body with all params."""
        full_resp = load_test_data("events_response.json")
        mock_req = mocker.patch.object(mock_client, "_http_request", return_value=full_resp)
        mock_client.fetch_events_page("1700000000", "1700100000", event_type=2, page_token="tok")
        body = mock_req.call_args.kwargs["json_data"]
        assert body == {"fromdate": "1700000000", "todate": "1700100000", "incidenttype": 2, "token": "tok"}

    def test_fetch_events_page_minimal_body(self, mocker: MockerFixture, mock_client: Client):
        """Without events_type and page_token, body only has dates."""
        full_resp = load_test_data("events_response.json")
        mock_req = mocker.patch.object(mock_client, "_http_request", return_value=full_resp)
        mock_client.fetch_events_page("1700000000", "1700100000")
        body = mock_req.call_args.kwargs["json_data"]
        assert body == {"fromdate": "1700000000", "todate": "1700100000"}

    def test_fetch_events_page_returns_result(self, mocker: MockerFixture, mock_client: Client):
        mocker.patch.object(mock_client, "_http_request", return_value=load_test_data("events_response.json"))
        result = mock_client.fetch_events_page("1700000000", "1700100000")
        assert "incidents" in result
        assert "success" not in result  # _validate_api_response strips the wrapper

    def test_fetch_events_page_rate_limit_propagates(self, mocker: MockerFixture, mock_client: Client):
        """A 429 rate-limit error must propagate and NOT be treated as an auth failure
        or silently swallowed.

        The 429 is raised by the HTTP layer, so on_auth_failure (which only handles
        401 re-auth) is never invoked and the error surfaces to the caller.
        """
        rate_limit_error = DemistoException("Error in API call [429] - Too Many Requests")
        mocker.patch.object(mock_client, "_http_request", side_effect=rate_limit_error)
        on_auth_failure_spy = mocker.patch.object(mock_client._auth_handler, "on_auth_failure", new_callable=AsyncMock)

        with pytest.raises(DemistoException, match="429"):
            mock_client.fetch_events_page("1700000000", "1700100000")

        # 429 is not an auth failure — re-authentication must not be triggered.
        on_auth_failure_spy.assert_not_called()


# endregion

# region Test Module Tests


class TestTestModule:
    def test_success(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        assert izoologic_test_module(mock_client, [2], is_fetch_events=True) == "ok"

    def test_success_empty_response(self, mocker: MockerFixture, mock_client: Client, empty_result: dict):
        """Empty result still proves connectivity — test passes."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=empty_result)
        assert izoologic_test_module(mock_client, [2], is_fetch_events=True) == "ok"

    @pytest.mark.parametrize("error_msg", ["401 Unauthorized", "403 Forbidden", "unauthorized"])
    def test_auth_failure(self, mocker: MockerFixture, mock_client: Client, error_msg: str):
        mocker.patch.object(mock_client, "fetch_events_page", side_effect=DemistoException(error_msg))
        assert "Authorization Error" in izoologic_test_module(mock_client, [2], is_fetch_events=True)

    def test_other_error_raises(self, mocker: MockerFixture, mock_client: Client):
        mocker.patch.object(mock_client, "fetch_events_page", side_effect=DemistoException("timeout"))
        with pytest.raises(DemistoException, match="timeout"):
            izoologic_test_module(mock_client, [2], is_fetch_events=True)

    def test_caps_results_to_one(
        self,
        mocker: MockerFixture,
        mock_client: Client,
        events_result_with_pagination: dict,
        events_result: dict,
    ):
        """test-module caps the fetch at a single event (max_results=1) and still returns ok."""
        mocker.patch.object(
            mock_client,
            "fetch_events_page",
            side_effect=[events_result_with_pagination, events_result, events_result],
        )
        assert izoologic_test_module(mock_client, [2], is_fetch_events=True) == "ok"

    def test_validates_command_then_each_configured_type_when_fetch_enabled(
        self, mocker: MockerFixture, mock_client: Client, events_result: dict
    ):
        """With fetch enabled, test-module validates the command path (no type filter)
        then fetches one event per configured type, filtering by each type code."""
        mock_fetch = mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)

        assert izoologic_test_module(mock_client, [1, 2, 3], is_fetch_events=True) == "ok"

        # First call validates command functionality (search, no event_type),
        # then one call per configured type filtered by its event_type code.
        called_types = [call.kwargs.get("event_type") for call in mock_fetch.call_args_list]
        assert called_types == [None, 1, 2, 3]

    def test_collector_skipped_when_fetch_disabled(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """With fetch disabled, only the command path is validated — the per-type
        collector checks are skipped entirely."""
        mock_fetch = mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)

        assert izoologic_test_module(mock_client, [1, 2, 3], is_fetch_events=False) == "ok"

        # Only the command-functionality call runs (no event_type); no per-type calls.
        called_types = [call.kwargs.get("event_type") for call in mock_fetch.call_args_list]
        assert called_types == [None]

    def test_no_configured_types_still_ok(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """With no configured types but fetch enabled, only the command path runs."""
        mock_fetch = mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        assert izoologic_test_module(mock_client, [], is_fetch_events=True) == "ok"
        # Only the command-functionality call (no event_type); no per-type calls.
        called_types = [call.kwargs.get("event_type") for call in mock_fetch.call_args_list]
        assert called_types == [None]


# endregion

# region Fetch All Pages Tests


class TestFetchAllPages:
    def test_single_page(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        results = _fetch_all_pages(mock_client, "1700000000", "1700100000", event_type=2)
        assert len(results) == 3

    def test_multi_page(
        self,
        mocker: MockerFixture,
        mock_client: Client,
        events_result_with_pagination: dict,
        events_result: dict,
    ):
        """Exhausts all pages until nextPage is null."""
        mocker.patch.object(
            mock_client,
            "fetch_events_page",
            side_effect=[events_result_with_pagination, events_result],
        )
        results = _fetch_all_pages(mock_client, "1700000000", "1700100000", event_type=2)
        # Page 1: 2 events (with pagination), Page 2: 3 events (no pagination)
        assert len(results) == 5

    def test_max_results_caps_oldest(
        self,
        mocker: MockerFixture,
        mock_client: Client,
        events_result_with_pagination: dict,
        events_result: dict,
    ):
        """max_results caps to the oldest N after sorting (pagination still exhausts)."""
        mocker.patch.object(
            mock_client,
            "fetch_events_page",
            side_effect=[events_result_with_pagination, events_result],
        )
        results = _fetch_all_pages(mock_client, "1700000000", "1700100000", event_type=2, max_results=1)
        # Returns exactly one event — the oldest across all pages (createdOn 1700000000).
        assert len(results) == 1
        assert results[0]["createdOn"] == "1700000000"

    def test_watermark_stops_pagination(
        self,
        mocker: MockerFixture,
        mock_client: Client,
        events_result_with_pagination: dict,
        events_result: dict,
    ):
        """Pagination stops early once an event older than from_ts appears on a page."""
        mock_fetch = mocker.patch.object(
            mock_client,
            "fetch_events_page",
            side_effect=[events_result_with_pagination, events_result],
        )
        # Page 1 (events_result_with_pagination) has createdOn 1700001100, 1700001000.
        # With from_ts just above the lower value, the page contains an older event,
        # so pagination stops after the first call.
        _fetch_all_pages(mock_client, "1700000000", "1700100000", event_type=2, from_ts="1700001050")
        mock_fetch.assert_called_once()

    def test_empty_response(self, mocker: MockerFixture, mock_client: Client, empty_result: dict):
        mocker.patch.object(mock_client, "fetch_events_page", return_value=empty_result)
        results = _fetch_all_pages(mock_client, "1700000000", "1700100000", event_type=2)
        assert results == []

    def test_no_event_type(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Works without event_type filter."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        results = _fetch_all_pages(mock_client, "1700000000", "1700100000")
        assert len(results) == 3

    def test_from_ts_filters_older_events(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Events with createdOn below from_ts are dropped (precise lower bound)."""
        # Fixture createdOn values: 1700000200, 1700000100, 1700000000.
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        results = _fetch_all_pages(mock_client, "1700000000", "1700100000", event_type=2, from_ts="1700000100")
        ids = [inc["incidentID"] for inc in results]
        # Only events at/after 1700000100 remain (def456, abc123); 1700000000 dropped.
        assert ids == ["def456", "abc123"]

    def test_to_ts_filters_newer_events(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Events with createdOn above to_ts are dropped (precise upper bound)."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        results = _fetch_all_pages(mock_client, "1700000000", "1700100000", event_type=2, to_ts="1700000100")
        ids = [inc["incidentID"] for inc in results]
        # Only events at/before 1700000100 remain (ghi789, def456); 1700000200 dropped.
        assert ids == ["ghi789", "def456"]

    def test_results_sorted_ascending(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Results are returned sorted ascending by createdOn (oldest first)."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        results = _fetch_all_pages(mock_client, "1700000000", "1700100000", event_type=2)
        created = [inc["createdOn"] for inc in results]
        assert created == ["1700000000", "1700000100", "1700000200"]


# endregion

# region In-Range Helper Tests


class TestIsInRange:
    @pytest.mark.parametrize(
        "created_on, from_threshold, to_threshold, expected",
        [
            # No bounds — everything is in range.
            ("150", None, None, True),
            # Lower bound only — inclusive at the boundary.
            ("100", 100, None, True),
            ("99", 100, None, False),
            ("101", 100, None, True),
            # Upper bound only — inclusive at the boundary.
            ("200", None, 200, True),
            ("201", None, 200, False),
            ("199", None, 200, True),
            # Both bounds — inside, on edges, and outside.
            ("150", 100, 200, True),
            ("100", 100, 200, True),
            ("200", 100, 200, True),
            ("99", 100, 200, False),
            ("201", 100, 200, False),
        ],
    )
    def test_is_in_range(self, created_on: str, from_threshold: int | None, to_threshold: int | None, expected: bool):
        assert _is_in_range({"createdOn": created_on}, from_threshold, to_threshold) is expected

    def test_missing_created_on_defaults_to_zero(self):
        """An event without createdOn is treated as createdOn=0."""
        assert _is_in_range({}, 100, 200) is False
        assert _is_in_range({}, None, None) is True

    def test_from_threshold_zero_epoch_boundary(self):
        """A from_threshold of 0 (epoch) is treated as a real bound via the
        ``is not None`` check, not skipped as a falsy value.

        createdOn == 0 is inclusive at the boundary, while a negative createdOn
        is rejected. This locks in the is-not-None boundary behavior.
        """
        assert _is_in_range({"createdOn": "0"}, 0, None) is True
        assert _is_in_range({"createdOn": "100"}, 0, None) is True
        assert _is_in_range({"createdOn": "-1"}, 0, None) is False


# endregion

# region Resolve Fetch Window Tests


class TestResolveFetchWindow:
    def test_same_day_snaps_to_end_of_day(self):
        """When from and to land on the same UTC day, to_date snaps to end-of-day."""
        from_ts, from_date, to_date = _resolve_fetch_window("2023-11-14T10:00:00Z", "2023-11-14T12:00:00Z")
        # from_ts is the precise requested start (before the day snap).
        assert from_ts == date_to_unix_timestamp("2023-11-14T10:00:00Z")
        # from_date is snapped to start-of-day; to_date snapped to end-of-day so the
        # API accepts the equal [Day, Day] range.
        assert from_date == snap_to_day_boundary_utc(from_ts, "start")
        assert int(to_date) > int(from_date)

    def test_default_to_date_is_now(self, mocker: MockerFixture):
        """When to_input is None, to_date defaults to the current timestamp."""
        mocker.patch("iZOOlogic.get_current_unix_timestamp", return_value="1700100000")
        from_ts, from_date, to_date = _resolve_fetch_window("2023-11-14T10:00:00Z", None)
        assert to_date == "1700100000"
        assert from_ts == date_to_unix_timestamp("2023-11-14T10:00:00Z")

    def test_inverted_range_raises(self):
        """A from after to (different days) raises via validate_date_range."""
        with pytest.raises(DemistoException, match="is before"):
            _resolve_fetch_window("2023-11-20T00:00:00Z", "2023-11-10T00:00:00Z")


# region Filter and Dedup Tests


class TestFetchAllPagesDedup:
    @pytest.mark.parametrize(
        "last_ids, expected_ids",
        [
            # No previously-seen IDs — nothing removed.
            ([], ["a", "b", "c"]),
            # Remove a single previously-seen ID.
            (["b"], ["a", "c"]),
            # Remove multiple previously-seen IDs.
            (["a", "c"], ["b"]),
            # All IDs already seen.
            (["a", "b", "c"], []),
        ],
    )
    def test_fetch_all_pages_dedups_last_ids(
        self,
        mocker: MockerFixture,
        mock_client: Client,
        last_ids: list[str],
        expected_ids: list[str],
    ):
        # _fetch_all_pages drops events whose IDs are in last_ids and returns the
        # rest sorted ascending by createdOn.
        page = {
            "incidents": [
                {"incidentID": "a", "createdOn": "100"},
                {"incidentID": "b", "createdOn": "200"},
                {"incidentID": "c", "createdOn": "300"},
            ],
            "nextPage": None,
        }
        mocker.patch.object(mock_client, "fetch_events_page", return_value=page)
        result = _fetch_all_pages(mock_client, "100", "300", event_type=2, last_ids=last_ids)
        assert [inc["incidentID"] for inc in result] == expected_ids


# endregion

# region Compute New State Tests


class TestComputeNewState:
    @pytest.mark.parametrize(
        "consumed, expected_created_on, expected_ids",
        [
            # Single event at max
            (
                [{"incidentID": "a", "createdOn": "100"}, {"incidentID": "b", "createdOn": "200"}],
                200,
                ["b"],
            ),
            # Multiple events at max timestamp
            (
                [
                    {"incidentID": "a", "createdOn": "100"},
                    {"incidentID": "b", "createdOn": "200"},
                    {"incidentID": "c", "createdOn": "200"},
                ],
                200,
                ["b", "c"],
            ),
            # Single event
            (
                [{"incidentID": "x", "createdOn": "500"}],
                500,
                ["x"],
            ),
        ],
    )
    def test_compute_new_state(
        self,
        consumed: list[dict],
        expected_created_on: int,
        expected_ids: list[str],
    ):
        state = _compute_new_state(consumed, type_key="1")
        assert state["last_created_on"] == expected_created_on
        assert set(state["last_ids"]) == set(expected_ids)

    def test_dedup_with_inconsistent_timestamp_formats(self):
        """last_ids collects ALL IDs at the max createdOn even when the API returns
        inconsistently-formatted timestamps (e.g. "200" vs " 200").

        This protects the boundary dedup logic: comparison is normalized to int so a
        whitespace-padded duplicate of the max timestamp is still grouped together.
        """
        consumed = [
            {"incidentID": "a", "createdOn": "100"},
            {"incidentID": "b", "createdOn": "200"},
            {"incidentID": "c", "createdOn": " 200"},
        ]
        state = _compute_new_state(consumed, type_key="1")
        assert state["last_created_on"] == 200
        assert set(state["last_ids"]) == {"b", "c"}


# endregion

# region Fetch For Type Tests


class TestFetchForType:
    def test_first_fetch(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """First fetch with empty state — all events consumed, sorted ascending."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)

        type_key, cortex_events, state = _fetch_for_type(mock_client, 2, {}, 10000)

        assert type_key == "2"
        assert len(cortex_events) == 3
        # State should have last_created_on = max createdOn (ascending sort, last consumed)
        assert state["last_created_on"] == 1700000200
        # Only the event at max createdOn should be in last_ids
        assert state["last_ids"] == ["abc123"]

    def test_ascending_sort(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Verify events are returned sorted ascending by createdOn."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)

        _, consumed_events, _ = _fetch_for_type(mock_client, 2, {}, 10000)

        created_ons = [e["createdOn"] for e in consumed_events]
        assert created_ons == ["1700000000", "1700000100", "1700000200"]

    def test_slice_to_max_fetch(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """When max_fetch < total events, slice to max_fetch (oldest first)."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)

        _, consumed_events, state = _fetch_for_type(mock_client, 2, {}, 2)

        assert len(consumed_events) == 2
        # Should consume the 2 oldest (ascending sort)
        ids = [e["incidentID"] for e in consumed_events]
        assert ids == ["ghi789", "def456"]
        # last_created_on = createdOn of the last consumed (def456 = 1700000100)
        assert state["last_created_on"] == 1700000100
        assert state["last_ids"] == ["def456"]

    def test_time_filter(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Events with createdOn < last_created_on are discarded."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        mocker.patch("iZOOlogic.get_current_unix_timestamp", return_value="1700100000")

        type_state = {"last_created_on": "1700000100", "last_ids": []}
        _, consumed_events, state = _fetch_for_type(mock_client, 2, type_state, 10000)

        # ghi789 (createdOn=1700000000) should be filtered out
        ids = [e["incidentID"] for e in consumed_events]
        assert "ghi789" not in ids
        assert len(consumed_events) == 2

    def test_dedup_at_boundary(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Events with createdOn == last_created_on and matching IDs are removed."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        mocker.patch("iZOOlogic.get_current_unix_timestamp", return_value="1700100000")

        type_state = {"last_created_on": "1700000100", "last_ids": ["def456"]}
        _, consumed_events, _ = _fetch_for_type(mock_client, 2, type_state, 10000)

        ids = [e["incidentID"] for e in consumed_events]
        assert "def456" not in ids
        assert "ghi789" not in ids  # Filtered by time
        assert ids == ["abc123"]

    def test_empty_response_advances_cursor(self, mocker: MockerFixture, mock_client: Client, empty_result: dict):
        """When no events are returned, cursor advances to to_date."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=empty_result)
        mocker.patch("iZOOlogic.get_current_unix_timestamp", return_value="1700100000")
        # Mock date_to_unix_timestamp so DEFAULT_FROM_TIME doesn't resolve to "now"
        mocker.patch("iZOOlogic.date_to_unix_timestamp", return_value="1700000000")

        type_key, cortex_events, state = _fetch_for_type(mock_client, 2, {}, 10000)

        assert cortex_events == []
        assert state["last_created_on"] == 1700100000
        assert state["last_ids"] == []

    def test_all_filtered_out_advances_cursor(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """When all events are filtered/deduped out, cursor advances to to_date."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        mocker.patch("iZOOlogic.get_current_unix_timestamp", return_value="1700100000")

        type_state = {"last_created_on": "1700000200", "last_ids": ["abc123"]}
        _, cortex_events, state = _fetch_for_type(mock_client, 2, type_state, 10000)

        assert cortex_events == []
        assert state["last_created_on"] == 1700100000
        assert state["last_ids"] == []

    def test_state_update_with_multiple_same_timestamp(self, mocker: MockerFixture, mock_client: Client):
        """When multiple events share the max createdOn, all their IDs are in last_ids."""
        mocker.patch("iZOOlogic.get_current_unix_timestamp", return_value="1000")
        # Mock date_to_unix_timestamp so DEFAULT_FROM_TIME doesn't resolve to "now"
        mocker.patch("iZOOlogic.date_to_unix_timestamp", return_value="100")
        result = {
            "incidents": [
                {"incidentID": "a", "createdOn": "200", "incidentType": "Phishing"},
                {"incidentID": "b", "createdOn": "200", "incidentType": "Phishing"},
                {"incidentID": "c", "createdOn": "100", "incidentType": "Phishing"},
            ],
            "nextPage": None,
        }
        mocker.patch.object(mock_client, "fetch_events_page", return_value=result)

        _, _, state = _fetch_for_type(mock_client, 2, {}, 10000)

        assert state["last_created_on"] == 200
        assert set(state["last_ids"]) == {"a", "b"}

    def test_large_date_range_raises(self, mocker: MockerFixture, mock_client: Client, empty_result: dict):
        """When date range exceeds 31 days, validate_date_range raises."""
        mocker.patch("iZOOlogic.get_current_unix_timestamp", return_value="1703000000")
        mocker.patch.object(mock_client, "fetch_events_page", return_value=empty_result)

        # last_created_on is >31 days before to_date → should raise
        type_state = {"last_created_on": "1700000000", "last_ids": ["old"]}
        with pytest.raises(DemistoException, match="exceeds the maximum"):
            _fetch_for_type(mock_client, 2, type_state, 10000)


# endregion

# region Get Events Command Tests


class TestGetEventsCommand:
    # Window bracketing the fixture events' createdOn (1700000000-1700000200) so they
    # survive the in-range filter now applied inside _fetch_all_pages.
    FIXTURE_WINDOW = {"start_time": "2023-11-14T00:00:00Z", "end_time": "2023-11-14T23:59:59Z"}

    def test_basic(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        result = get_events_command(mock_client, {"limit": "10", **self.FIXTURE_WINDOW}, [2])
        assert isinstance(result, CommandResults)
        assert result.outputs_prefix == "iZOOlogic.Incident"

    def test_slices_to_limit(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Test that get-events slices results to the limit per type."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        result = get_events_command(mock_client, {"limit": "2", **self.FIXTURE_WINDOW}, [2])
        assert len(result.outputs) <= 2  # type: ignore[arg-type]

    def test_invalid_limit(self, mocker: MockerFixture, mock_client: Client):
        with pytest.raises(DemistoException, match="Invalid limit value"):
            get_events_command(mock_client, {"limit": "-5"}, [2])

    def test_inverted_date_range_raises(self, mocker: MockerFixture, mock_client: Client):
        """end_time before start_time (different days) should raise."""
        with pytest.raises(DemistoException, match="is before"):
            get_events_command(
                mock_client,
                {
                    "limit": "10",
                    "start_time": "2024-01-15T00:00:00Z",
                    "end_time": "2024-01-10T00:00:00Z",
                },
                [2],
            )

    def test_date_range_exceeds_31_days_raises(self, mocker: MockerFixture, mock_client: Client):
        """get_events_command should reject date ranges exceeding 31 days."""
        with pytest.raises(DemistoException, match="exceeds the maximum"):
            get_events_command(
                mock_client,
                {
                    "limit": "10",
                    "start_time": "2024-01-01T00:00:00Z",
                    "end_time": "2024-03-01T00:00:00Z",
                },
                [2],
            )

    def test_event_type_arg_overrides_default(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """When event_type is provided in args, it overrides default_type_codes."""
        mock_fetch = mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        get_events_command(mock_client, {"limit": "10", "event_type": "malware"}, [2])
        # Should call with type_code=3 (malware), not 2 (phishing)
        call_body = mock_fetch.call_args.kwargs
        assert call_body.get("event_type") == 3

    def test_multiple_types(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Fetches events for each type code — API called once per type."""
        mock_fetch = mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        result = get_events_command(mock_client, {"limit": "10", **self.FIXTURE_WINDOW}, [2, 3])
        # Verify fetch_events_page was called for each type
        called_types = [call.kwargs["event_type"] for call in mock_fetch.call_args_list]
        assert 2 in called_types
        assert 3 in called_types
        assert isinstance(result.outputs, list)

    def test_outputs_key_field(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Verify outputs_key_field is set correctly."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        result = get_events_command(mock_client, {"limit": "10", **self.FIXTURE_WINDOW}, [2])
        assert result.outputs_key_field == "incidentID"

    def test_should_push_events_overridden_on_non_xsiam(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Test that should_push_events is silently overridden to False on non-XSIAM platforms."""
        # resolve_should_push_events lives in CommonServerPython and resolves is_xsiam from its own namespace.
        mocker.patch("CommonServerPython.is_xsiam", return_value=False)
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        mock_create = mocker.patch("iZOOlogic.create_events")

        result = get_events_command(mock_client, {"limit": "10", "should_push_events": "true", **self.FIXTURE_WINDOW}, [2])

        # Events should NOT be pushed (create_events should not be called)
        mock_create.assert_not_called()
        # Events should be returned as CommandResults
        assert isinstance(result, CommandResults)
        assert "iZOOlogic Events" in result.readable_output

    def test_should_push_events_pushed_on_xsiam(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Test that should_push_events pushes events when running on Cortex XSIAM."""
        # resolve_should_push_events lives in CommonServerPython and resolves is_xsiam from its own namespace.
        mocker.patch("CommonServerPython.is_xsiam", return_value=True)
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        mock_create = mocker.patch("iZOOlogic.create_events")

        # Use a time window that brackets the fixture events' createdOn (1700000000-1700000200)
        # so they survive the command's client-side time filter and get pushed.
        result = get_events_command(
            mock_client,
            {
                "limit": "10",
                "should_push_events": "true",
                "start_time": "2023-11-14T00:00:00Z",
                "end_time": "2023-11-14T23:59:59Z",
            },
            [2],
        )

        # Events should be pushed (create_events should be called)
        mock_create.assert_called_once()
        # Events should also be returned as CommandResults
        assert isinstance(result, CommandResults)
        assert "iZOOlogic Events" in result.readable_output


# endregion

# region Fetch Events Command Tests


class TestFetchEventsCommand:
    def test_first_fetch(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mock_send = mocker.patch("iZOOlogic.send_events_to_xsiam")
        mock_set = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client, 10000, [2])

        mock_send.assert_called_once()
        sent_events = mock_send.call_args[1]["events"]
        assert len(sent_events) == 3
        last_run = mock_set.call_args[0][0]
        assert "2" in last_run
        assert last_run["2"]["last_created_on"] == 1700000200
        assert last_run["2"]["last_ids"] == ["abc123"]

    def test_multiple_types_concurrent(
        self,
        mocker: MockerFixture,
        mock_client: Client,
        events_result: dict,
        empty_result: dict,
    ):
        """Test that multiple types are fetched (concurrently via ThreadPoolExecutor)."""
        mocker.patch.object(mock_client, "fetch_events_page", side_effect=[events_result, empty_result])
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mock_send = mocker.patch("iZOOlogic.send_events_to_xsiam")
        mock_set = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client, 10000, [2, 3])

        mock_send.assert_called_once()
        sent_events = mock_send.call_args[1]["events"]
        assert len(sent_events) == 3
        last_run = mock_set.call_args[0][0]
        assert "2" in last_run
        assert "3" in last_run

    def test_empty_response(self, mocker: MockerFixture, mock_client: Client, empty_result: dict):
        mocker.patch.object(mock_client, "fetch_events_page", return_value=empty_result)
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mock_send = mocker.patch("iZOOlogic.send_events_to_xsiam")
        mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client, 10000, [2])
        mock_send.assert_not_called()

    def test_exception_in_one_type_does_not_block_others(
        self,
        mocker: MockerFixture,
        mock_client: Client,
        events_result: dict,
    ):
        """If one type raises an exception, other types still succeed."""

        def side_effect(client, type_code, type_state, max_fetch):
            if type_code == 3:
                raise DemistoException("API error for type 3")
            return _fetch_for_type(client, type_code, type_state, max_fetch)

        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mocker.patch("iZOOlogic._fetch_for_type", side_effect=side_effect)
        mocker.patch("iZOOlogic.send_events_to_xsiam")
        mock_set = mocker.patch.object(demisto, "setLastRun")
        mocker.patch.object(demisto, "error")

        fetch_events_command(mock_client, 10000, [2, 3])

        # Type 2 should still succeed, type 3 error is logged
        last_run = mock_set.call_args[0][0]
        assert "2" in last_run

    def test_preserves_existing_last_run_keys(self, mocker: MockerFixture, mock_client: Client, events_result: dict):
        """Existing last_run keys for other types are preserved."""
        existing_last_run = {"5": {"last_created_on": "999", "last_ids": ["old"]}}
        mocker.patch.object(mock_client, "fetch_events_page", return_value=events_result)
        mocker.patch.object(demisto, "getLastRun", return_value=existing_last_run)
        mocker.patch("iZOOlogic.send_events_to_xsiam")
        mock_set = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client, 10000, [2])

        last_run = mock_set.call_args[0][0]
        assert "5" in last_run  # Preserved
        assert "2" in last_run  # New


# endregion

# region Main Tests


class TestMain:
    @pytest.mark.parametrize(
        "command",
        ["test-module", "fetch-events", "izoologic-get-events", "izoologic-incident-create", "izoologic-incident-fetch"],
    )
    def test_main_dispatches(self, mocker: MockerFixture, command: str):
        mocker.patch("ContentClientApiModule.support_multithreading")
        mocker.patch.object(demisto, "command", return_value=command)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.izoologic.com",
                "api_key": {"password": "k"},
                "secret_key": {"password": "s"},
                "max_fetch": "1000",
                "event_types_filter": ["phishing"],
            },
        )
        mocker.patch.object(demisto, "args", return_value={"limit": "10"})
        mock_func = mocker.MagicMock(return_value="ok")
        COMMAND_MAP[command] = mock_func
        mocker.patch("iZOOlogic.return_results")
        main()
        mock_func.assert_called_once()

    def test_main_unknown_command(self, mocker: MockerFixture):
        mocker.patch("ContentClientApiModule.support_multithreading")
        mocker.patch.object(demisto, "command", return_value="unknown")
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://x.com",
                "api_key": {"password": "k"},
                "secret_key": {"password": "s"},
            },
        )
        mocker.patch.object(demisto, "args", return_value={})
        mocker.patch.object(demisto, "error")
        mock_err = mocker.patch("iZOOlogic.return_error")
        main()
        mock_err.assert_called_once()

    def test_main_error_handling(self, mocker: MockerFixture):
        """Exceptions in command execution are caught and return_error is called."""
        mocker.patch("ContentClientApiModule.support_multithreading")
        mocker.patch.object(demisto, "command", return_value="test-module")
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.izoologic.com",
                "api_key": {"password": "k"},
                "secret_key": {"password": "s"},
            },
        )
        mocker.patch.object(demisto, "args", return_value={})
        mocker.patch.object(demisto, "error")
        # Patch COMMAND_MAP directly since main() reads from it, not from the module-level name
        error_func = mocker.MagicMock(side_effect=DemistoException("Connection refused"))
        COMMAND_MAP["test-module"] = error_func
        mock_err = mocker.patch("iZOOlogic.return_error")
        main()
        mock_err.assert_called_once()
        assert "Connection refused" in mock_err.call_args[0][0]


# endregion

# region Resolve Code By Name Tests


class TestResolveCodeByName:
    @pytest.mark.parametrize(
        "raw_value, code_map, expected",
        [
            ("phishing", ApiCodes.EVENT_TYPE, 2),
            ("PHISHING", ApiCodes.EVENT_TYPE, 2),
            (" phishing ", ApiCodes.EVENT_TYPE, 2),
            ("low threat", ApiCodes.THREAT_TYPE, 10),
            ("critical threat", ApiCodes.THREAT_TYPE, 14),
            ("incident", ApiCodes.CASE_TYPE, 6),
            ("domain monitoring", ApiCodes.CASE_TYPE, 1),
        ],
    )
    def test_valid_names(self, raw_value: str, code_map: dict, expected: int):
        assert _resolve_code_by_name(raw_value, code_map, "test_field") == expected

    @pytest.mark.parametrize(
        "raw_value, code_map",
        [
            ("nonexistent", ApiCodes.EVENT_TYPE),
            ("999", ApiCodes.EVENT_TYPE),
            ("2", ApiCodes.EVENT_TYPE),  # Integer strings not accepted
            ("invalid", ApiCodes.THREAT_TYPE),
        ],
    )
    def test_invalid_names_raise(self, raw_value: str, code_map: dict):
        with pytest.raises(DemistoException, match="Invalid 'test_field'"):
            _resolve_code_by_name(raw_value, code_map, "test_field")


# endregion

# region Validate Incident Creation Args Tests


class TestValidateIncidentCreationArgs:
    @pytest.fixture
    def valid_create_args(self) -> dict:
        return {
            "incident_url": "https://malicious-site.example.com",
            "incident_type": "phishing",
            "brand_code": "BRAND001",
        }

    def test_valid_required_only(self, valid_create_args: dict):
        result = _validate_incident_creation_args(valid_create_args)
        assert result["incident_url"] == "https://malicious-site.example.com"
        assert result["incident_type"] == 2
        assert result["brand_code"] == "BRAND001"
        assert result["threat_type"] is None
        assert result["case_type"] is None
        assert result["comment"] is None
        assert result["executive_name"] is None
        assert result["client_code"] is None

    def test_valid_all_args(self, valid_create_args: dict):
        valid_create_args.update(
            {
                "threat_type": "critical threat",
                "case_type": "incident",
                "comment": "Test comment",
                "executive_name": "John Doe",
                "client_code": "CLIENT001",
            }
        )
        result = _validate_incident_creation_args(valid_create_args)
        assert result["incident_type"] == 2
        assert result["threat_type"] == 14
        assert result["case_type"] == 6
        assert result["comment"] == "Test comment"
        assert result["executive_name"] == "John Doe"
        assert result["client_code"] == "CLIENT001"

    @pytest.mark.parametrize(
        "missing_field",
        ["incident_url", "incident_type", "brand_code"],
    )
    def test_missing_required_raises(self, valid_create_args: dict, missing_field: str):
        valid_create_args[missing_field] = ""
        with pytest.raises(DemistoException, match=f"'{missing_field}' is a required argument"):
            _validate_incident_creation_args(valid_create_args)

    def test_invalid_incident_type_raises(self, valid_create_args: dict):
        valid_create_args["incident_type"] = "nonexistent"
        with pytest.raises(DemistoException, match="Invalid 'incident_type'"):
            _validate_incident_creation_args(valid_create_args)

    def test_invalid_threat_type_raises(self, valid_create_args: dict):
        valid_create_args["threat_type"] = "invalid"
        with pytest.raises(DemistoException, match="Invalid 'threat_type'"):
            _validate_incident_creation_args(valid_create_args)

    def test_invalid_case_type_raises(self, valid_create_args: dict):
        valid_create_args["case_type"] = "invalid"
        with pytest.raises(DemistoException, match="Invalid 'case_type'"):
            _validate_incident_creation_args(valid_create_args)

    def test_case_insensitive_incident_type(self, valid_create_args: dict):
        valid_create_args["incident_type"] = "PHISHING"
        result = _validate_incident_creation_args(valid_create_args)
        assert result["incident_type"] == 2

    def test_executive_type(self, valid_create_args: dict):
        valid_create_args["incident_type"] = "executive"
        result = _validate_incident_creation_args(valid_create_args)
        assert result["incident_type"] == 56


# endregion

# region Client Report New Incident Tests


class TestClientReportNewIncident:
    def test_report_new_incident_full_body(self, mocker: MockerFixture, mock_client: Client):
        """Test that report_new_incident sends correct body with all params."""
        api_response = load_test_data("create_incident_response.json")
        mock_req = mocker.patch.object(mock_client, "_http_request", return_value=api_response)

        mock_client.report_new_incident(
            incident_url="https://malicious.example.com",
            incident_type=2,
            brand_code="BRAND001",
            threat_type=14,
            case_type=6,
            comment="Test comment",
            executive_name="John Doe",
            client_code="CLIENT001",
        )

        body = mock_req.call_args.kwargs["json_data"]
        assert body["incidenturl"] == "https://malicious.example.com"
        assert body["incidenttype"] == 2
        assert body["brandcode"] == "BRAND001"
        assert body["threattype"] == 14
        assert body["casetype"] == 6
        assert body["comment"] == "Test comment"
        assert body["executivename"] == "John Doe"
        assert body["clientcode"] == "CLIENT001"

    def test_report_new_incident_minimal_body(self, mocker: MockerFixture, mock_client: Client):
        """Without optional params, body only has required fields."""
        api_response = load_test_data("create_incident_response.json")
        mock_req = mocker.patch.object(mock_client, "_http_request", return_value=api_response)

        mock_client.report_new_incident(
            incident_url="https://malicious.example.com",
            incident_type=2,
            brand_code="BRAND001",
        )

        body = mock_req.call_args.kwargs["json_data"]
        assert body == {
            "incidenturl": "https://malicious.example.com",
            "incidenttype": 2,
            "brandcode": "BRAND001",
        }
        # Optional fields should not be present (assign_params removes None values)
        assert "threattype" not in body
        assert "casetype" not in body
        assert "comment" not in body
        assert "executivename" not in body
        assert "clientcode" not in body


# endregion

# region Create Incident Command Tests


class TestCreateIncidentCommand:
    def test_success(self, mocker: MockerFixture, mock_client: Client):
        api_response = load_test_data("create_incident_response.json")
        mocker.patch.object(mock_client, "_http_request", return_value=api_response)

        args = {
            "incident_url": "https://malicious.example.com",
            "incident_type": "phishing",
            "brand_code": "BRAND001",
        }
        result = create_incident_command(mock_client, args)

        assert isinstance(result, CommandResults)
        assert result.outputs_prefix == "iZOOlogic.Incident"
        assert result.outputs_key_field == "reportedIncidentId"
        assert result.outputs["reportedIncidentId"] == "ycB2E7gPQ"
        assert result.outputs["statusCode"] == 1

    def test_api_error_raises(self, mocker: MockerFixture, mock_client: Client):
        error_response = {
            "success": False,
            "message": "Invalid brand code",
            "errorCode": "iZOO4001",
            "result": None,
        }
        mocker.patch.object(mock_client, "_http_request", return_value=error_response)

        args = {
            "incident_url": "https://malicious.example.com",
            "incident_type": "phishing",
            "brand_code": "INVALID",
        }
        with pytest.raises(DemistoException, match="Failed to create incident"):
            create_incident_command(mock_client, args)

    def test_with_all_optional_args(self, mocker: MockerFixture, mock_client: Client):
        api_response = load_test_data("create_incident_response.json")
        mocker.patch.object(mock_client, "_http_request", return_value=api_response)

        args = {
            "incident_url": "https://malicious.example.com",
            "incident_type": "executive",
            "brand_code": "BRAND001",
            "threat_type": "critical threat",
            "case_type": "executive monitoring",
            "comment": "Executive impersonation detected",
            "executive_name": "Jane Smith",
            "client_code": "CLIENT001",
        }
        result = create_incident_command(mock_client, args)

        assert isinstance(result, CommandResults)
        assert result.outputs["reportedIncidentId"] == "ycB2E7gPQ"

    def test_readable_output(self, mocker: MockerFixture, mock_client: Client):
        api_response = load_test_data("create_incident_response.json")
        mocker.patch.object(mock_client, "_http_request", return_value=api_response)

        args = {
            "incident_url": "https://malicious.example.com",
            "incident_type": "phishing",
            "brand_code": "BRAND001",
        }
        result = create_incident_command(mock_client, args)

        assert "New Incident Created" in result.readable_output
        assert "ycB2E7gPQ" in result.readable_output


# endregion

# region Incident Fetch Command Tests


class TestSearchIncidentsCommand:
    # Fixtures use createdOn in [1700000000, 1700000200]; this window covers them.
    FIXTURE_WINDOW = {"from_date": "2023-11-14T00:00:00Z", "to_date": "2023-11-14T23:59:59Z"}

    def test_basic_no_args(self, mocker: MockerFixture, mock_client: Client, incidents_result: dict):
        """Fetch within the fixture window (no filters) returns incidents."""
        mocker.patch.object(mock_client, "fetch_events_page", return_value=incidents_result)
        result = search_incidents_command(mock_client, dict(self.FIXTURE_WINDOW))

        assert isinstance(result, CommandResults)
        assert result.outputs_prefix == "iZOOlogic.Incident"
        assert result.outputs_key_field == "incidentID"
        assert len(result.outputs) == 3  # type: ignore[arg-type]

    def test_with_all_filters(self, mocker: MockerFixture, mock_client: Client, incidents_result: dict):
        """Fetch with all filters passes them to the API."""
        mock_fetch = mocker.patch.object(mock_client, "fetch_events_page", return_value=incidents_result)

        args = {
            **self.FIXTURE_WINDOW,
            "incident_type": "phishing",
            "threat_type": "critical threat",
            "brand_code": "BRAND001",
            "executive_name": "John Doe",
            "client_ref_id": "REF123",
            "client_code": "CLIENT001",
        }
        result = search_incidents_command(mock_client, args)

        assert isinstance(result, CommandResults)
        # Verify the API was called with the correct filters
        call_kwargs = mock_fetch.call_args.kwargs
        assert call_kwargs["event_type"] == 2  # phishing
        assert call_kwargs["threat_type"] == 14  # critical threat
        assert call_kwargs["brand_code"] == "BRAND001"
        assert call_kwargs["executive_name"] == "John Doe"
        assert call_kwargs["client_ref_id"] == "REF123"
        assert call_kwargs["client_code"] == "CLIENT001"

    def test_invalid_incident_type_raises(self, mocker: MockerFixture, mock_client: Client):
        with pytest.raises(DemistoException, match="Invalid 'incident_type'"):
            search_incidents_command(mock_client, {"incident_type": "nonexistent"})

    def test_invalid_threat_type_raises(self, mocker: MockerFixture, mock_client: Client):
        with pytest.raises(DemistoException, match="Invalid 'threat_type'"):
            search_incidents_command(mock_client, {"threat_type": "invalid"})

    def test_inverted_date_range_raises(self, mocker: MockerFixture, mock_client: Client):
        with pytest.raises(DemistoException, match="is before"):
            search_incidents_command(
                mock_client,
                {
                    "from_date": "2024-01-15T00:00:00Z",
                    "to_date": "2024-01-10T00:00:00Z",
                },
            )

    def test_empty_response(self, mocker: MockerFixture, mock_client: Client, empty_result: dict):
        mocker.patch.object(mock_client, "fetch_events_page", return_value=empty_result)
        result = search_incidents_command(mock_client, {})

        assert isinstance(result, CommandResults)
        assert result.outputs == []

    def test_readable_output_has_headers(self, mocker: MockerFixture, mock_client: Client, incidents_result: dict):
        mocker.patch.object(mock_client, "fetch_events_page", return_value=incidents_result)
        result = search_incidents_command(mock_client, dict(self.FIXTURE_WINDOW))

        assert "iZOOlogic Incidents" in result.readable_output
        assert "Incident ID" in result.readable_output

    def test_existing_commands_dont_pass_new_params(self, mocker: MockerFixture, mock_client: Client, incidents_result: dict):
        """Verify that _fetch_all_pages called from get_events_command does NOT pass new filter params."""
        mock_fetch_all = mocker.patch("iZOOlogic._fetch_all_pages", return_value=[])
        mocker.patch.object(mock_client, "fetch_events_page", return_value=incidents_result)

        get_events_command(mock_client, {"limit": "10"}, [2])

        # _fetch_all_pages should be called without the new params
        call_kwargs = mock_fetch_all.call_args.kwargs
        assert "threat_type" not in call_kwargs
        assert "brand_code" not in call_kwargs
        assert "executive_name" not in call_kwargs
        assert "client_ref_id" not in call_kwargs
        assert "client_code" not in call_kwargs