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
| ID | iZOOlogic |
|---|---|
| Provider | iZOOlogic |
| Category | Network Security |
| From Version | 8.2.0 |
| Docker Image | demisto/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 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 settingsisFetchEvents—events_types_filter— Fetch incident types (required)max_fetch— Maximum incidents per fetch per type (required)
Commands (3)
-
izoologic-get-eventsGets 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-createCreates a new security incident in iZOOlogic.
-
izoologic-incident-fetchFetches 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