import json from datetime import datetime, UTC from typing import cast import pytest from CommonServerPython import * import OpenAiChatGPTV3 as module from OpenAiChatGPTV3 import ( CollectorParams, ComplianceEvent, Config, EmailParts, EventType, LastRunKey, OpenAiClient, SourceLogType, _build_completed_response_result, _build_response_readable_output, _build_responses_api_body, _extract_credential, _parse_json_or_concatenated, analyze_email_body_command, analyze_email_header_command, check_email_part, create_response_command, deduplicate_events, draft_soc_email_command, enrich_audit_event, enrich_compliance_event, event_id, extract_assistant_message, extract_response_output_text, fetch_audit_logs, fetch_compliance_logs, get_email_parts, list_models_command, create_moderation_command, validate_create_moderation_args, _entry_id_to_data_url, parse_collector_params, parse_concatenated_json, parse_event_types_to_fetch, parse_first_fetch_to_datetime, parse_integration_params, selected_audit_enabled, selected_compliance_event_types, send_message_command, validate_event_types_credentials_correlation, ) def util_load_json(path): with open(path, encoding="utf-8") as f: return json.loads(f.read()) def util_load_text(path: str) -> str: with open(path) as f: return f.read() # region Existing tests - GPT chat / email def test_extract_assistant_message(): """Tests extraction from a valid response with choices and message.""" mock_response = util_load_json("test_data/mock_response.json") extracted_message = extract_assistant_message(response=mock_response) assert extracted_message == "Hello! How can I assist you today?" @pytest.mark.parametrize("entry_id, should_raise_error", [("VALID_ENTRY_ID", False), ("INVALID_ENTRY_ID", True), ("", True)]) def test_get_email_parts(mocker, entry_id, should_raise_error): """Tests email parsing and parts extraction.""" def mock_file(_entry_id: str): if _entry_id == "VALID_ENTRY_ID": return {"path": "./test_data/attachment_malicious_url.eml", "name": "attachment_malicious_url.eml"} elif _entry_id == "INVALID_ENTRY_ID": return {"path": "./test_data/dummy_file.txt", "name": "dummy_file.txt"} return None mocker.patch.object(demisto, "getFilePath", side_effect=mock_file) if should_raise_error: with pytest.raises(Exception): get_email_parts(entry_id=entry_id) else: headers, text_body, html_body, file_name = get_email_parts(entry_id=entry_id) assert headers == util_load_json("test_data/expected_headers.json") assert text_body == "Body of the text" assert html_body.replace("\r\n", "\n") == util_load_text("test_data/expected_html_body.txt") @pytest.mark.parametrize( "email_part, args", [ (EmailParts.HEADERS, {"entryId": "XYZ", "additionalInstructions": "Identify spoofing."}), (EmailParts.BODY, {"entryId": "123", "additionalInstructions": "Identify data breaches."}), ], ) def test_check_email_parts(mocker, email_part: str, args: dict): """Tests 'check_email_parts' function.""" mocker.patch.object(OpenAiClient, "_http_request", return_value=util_load_json("test_data/mock_response.json")) mocker.patch.object( demisto, "getFilePath", return_value={"path": "./test_data/attachment_malicious_url.eml", "name": "attachment_malicious_url.eml"}, ) client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-4", proxy=False, verify=False) check_email_part(email_part, client, args) @pytest.mark.parametrize( "args, params", [ ( {"entry_id": "XYZ", "additional_instructions": "Pay close attention to SPF/DKIM."}, {}, ), ( {"entry_id": "XYZ"}, {"max_tokens": "200", "temperature": "0.5", "top_p": "0.9"}, ), ( { "entry_id": "XYZ", "max_tokens": "100", "temperature": "0.1", "top_p": "0.5", "reasoning_effort": "high", }, {}, ), ], ids=[ "with-additional-instructions", "with-instance-params-fallback", "with-all-args-and-reasoning", ], ) def test_analyze_email_header_command(mocker, args: dict, params: dict): """Tests 'analyze_email_header_command' using the Responses API.""" mock_response = util_load_json("test_data/mock_responses_api_response.json") mocker.patch.object(OpenAiClient, "_http_request", return_value=mock_response) mocker.patch.object( demisto, "getFilePath", return_value={"path": "./test_data/attachment_malicious_url.eml", "name": "attachment_malicious_url.eml"}, ) client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False) result = analyze_email_header_command(client, args, params) assert result.outputs_prefix == "OpenAiChatGPTV3.Response" assert isinstance(result.outputs, list) assert len(result.outputs) == 1 assert "SPF" in result.outputs[0]["assistant"] assert result.readable_output is not None assert "gpt-5" in result.readable_output def test_analyze_email_header_command_no_headers(mocker): """Tests that analyze_email_header_command raises when no headers are found.""" mocker.patch.object( demisto, "getFilePath", return_value={"path": "./test_data/dummy_file.txt", "name": "dummy_file.eml"}, ) client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False) with pytest.raises(Exception): analyze_email_header_command(client, {"entry_id": "XYZ"}, {}) @pytest.mark.parametrize( "args, params", [ ( {"entry_id": "XYZ", "additional_instructions": "Check for phishing links."}, {}, ), ( {"entry_id": "XYZ"}, {"max_tokens": "200", "temperature": "0.5", "top_p": "0.9"}, ), ( { "entry_id": "XYZ", "max_tokens": "100", "temperature": "0.1", "top_p": "0.5", "reasoning_effort": "high", }, {}, ), ], ids=[ "with-additional-instructions", "with-instance-params-fallback", "with-all-args-and-reasoning", ], ) def test_analyze_email_body_command(mocker, args: dict, params: dict): """Tests 'analyze_email_body_command' using the Responses API.""" mock_response = util_load_json("test_data/mock_responses_api_response.json") mocker.patch.object(OpenAiClient, "_http_request", return_value=mock_response) mocker.patch.object( demisto, "getFilePath", return_value={"path": "./test_data/attachment_malicious_url.eml", "name": "attachment_malicious_url.eml"}, ) client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False) result = analyze_email_body_command(client, args, params) assert result.outputs_prefix == "OpenAiChatGPTV3.Response" assert result.raw_response == mock_response assert isinstance(result.outputs, list) assert len(result.outputs) == 1 assert result.outputs[0]["response_id"] == "resp_XXXX" assert result.readable_output is not None assert "gpt-5" in result.readable_output def test_analyze_email_body_command_no_body(mocker): """Tests that analyze_email_body_command raises when no body is found.""" # Mock an .eml file that has headers but no body mocker.patch.object( demisto, "getFilePath", return_value={"path": "./test_data/dummy_file.txt", "name": "dummy_file.eml"}, ) client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False) with pytest.raises(Exception): analyze_email_body_command(client, {"entry_id": "XYZ"}, {}) @pytest.mark.parametrize( "args, params", [ ( {"additional_instructions": "Notify the user the email was quarantined."}, {}, ), ( {}, {"max_tokens": "200", "temperature": "0.5", "top_p": "0.9"}, ), ( { "additional_instructions": "Include remediation steps.", "max_tokens": "100", "temperature": "0.1", "top_p": "0.5", "reasoning_effort": "high", }, {}, ), ], ids=[ "with-additional-instructions", "with-instance-params-fallback", "with-all-args-and-reasoning", ], ) def test_draft_soc_email_command(mocker, args: dict, params: dict): """Tests 'draft_soc_email_command' using the Responses API.""" mock_response = util_load_json("test_data/mock_responses_api_response.json") mocker.patch.object(OpenAiClient, "_http_request", return_value=mock_response) client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-5", proxy=False, verify=False) result = draft_soc_email_command(client, args, params) assert result.outputs_prefix == "OpenAiChatGPTV3.Response" assert result.raw_response == mock_response assert isinstance(result.outputs, list) assert len(result.outputs) == 1 assert result.outputs[0]["response_id"] == "resp_XXXX" assert result.readable_output is not None assert "gpt-5" in result.readable_output def test_draft_soc_email_command_no_model(mocker): """Tests that draft_soc_email_command raises when no model is configured.""" client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="", proxy=False, verify=False) with pytest.raises(Exception, match="No model specified"): draft_soc_email_command(client, {}, {}) @pytest.mark.parametrize( "args", [ {"reset_conversation_history": True, "message": "Hi There!", "max_tokens": "100", "temperature": "0.1", "top_p": "0.1"}, { "reset_conversation_history": True, "message": "Hi There!", }, { "reset_conversation_history": False, "message": "Hi There!", }, ], ids=["test-send-message-with-params", "test-send-message-no-params", "test-send-message-no-reset"], ) def test_send_message_command(mocker, args): mocker.patch.object(OpenAiClient, "_http_request", return_value=util_load_json("test_data/mock_response.json")) mocker.patch.object( demisto, "context", return_value={ "OpenAiChatGPTV3": {"Conversation": [{"user": "Hi There!", "assistant": "Hello! How can I assist you today?"}]} }, ) client = OpenAiClient(url="DUMMY_URL", api_key="DUMMY_API_KEY", model="gpt-4", proxy=False, verify=False) result, _ = send_message_command(client, args) assert result.outputs_prefix == "OpenAiChatGPTV3.Conversation" # endregion # region Event Collector tests - shared helpers def _make_client(**overrides): """Build an OpenAiClient with all keys populated for event-collector tests. Pass `admin_api_key=""` / `compliance_api_key=""` / `chatgpt_base_url=...` via `overrides` to exercise guard branches. """ return OpenAiClient( url=overrides.get("url", "https://api.openai.com/"), api_key=overrides.get("api_key", "CHAT_KEY"), model=overrides.get("model", "gpt-4"), proxy=overrides.get("proxy", False), verify=overrides.get("verify", False), admin_api_key=overrides.get("admin_api_key", "ADMIN_KEY"), compliance_api_key=overrides.get("compliance_api_key", "COMPLIANCE_KEY"), chatgpt_base_url=overrides.get("chatgpt_base_url", "https://api.chatgpt.com"), ) # endregion # region Event Collector tests - small pure helpers @pytest.mark.parametrize( "event, expected", [ pytest.param({"id": "abc"}, "abc", id="happy-id-key"), pytest.param({"log_id": "xyz"}, "xyz", id="happy-log_id-fallback"), pytest.param({"event_id": 7}, "7", id="happy-numeric-coerced-to-string"), pytest.param({"uuid": "u-1"}, "u-1", id="happy-uuid-fallback"), pytest.param({"id": "primary", "log_id": "secondary"}, "primary", id="precedence-id-over-log_id"), pytest.param({"unrelated": "v"}, None, id="bad-no-known-key"), pytest.param({}, None, id="bad-empty-dict"), ], ) def test_event_id(event, expected): """`event_id` picks the first present key in (id, log_id, event_id, uuid).""" assert event_id(event) == expected @pytest.mark.parametrize( "events, previous_ids, expected_ids", [ pytest.param([{"id": "1"}, {"id": "2"}, {"id": "3"}], ["1", "3"], ["2"], id="happy-filters-known"), pytest.param([{"id": "1"}, {"id": "2"}], [], ["1", "2"], id="happy-no-previous-returns-all"), pytest.param([], ["1"], [], id="bad-empty-events-returns-empty"), pytest.param([{"id": "1"}, {"id": "1"}], ["1"], [], id="edge-all-events-filtered"), ], ) def test_deduplicate_events(events, previous_ids, expected_ids): """`deduplicate_events` drops events whose id is in `previous_ids`.""" result = deduplicate_events(events, previous_ids=previous_ids) assert [e["id"] for e in result] == expected_ids @pytest.mark.parametrize( "event, expect_time", [ pytest.param( {"id": "a", "effective_at": int(datetime(2099, 1, 1, tzinfo=UTC).timestamp())}, "2099-01-01T00:00:00Z", id="happy-effective_at-mapped-to-_time", ), pytest.param({"id": "a"}, None, id="bad-missing-effective_at-no-_time"), pytest.param({"id": "a", "effective_at": "not-a-number"}, None, id="bad-non-numeric-effective_at-no-_time"), ], ) def test_enrich_audit_event(event, expect_time): """Audit enrichment: strict `_time` from `effective_at` only. Audit events are routed to a dedicated dataset, so no `source_log_type` field is added (only Compliance events need it because they all share one dataset). """ enrich_audit_event(event) # `source_log_type` is intentionally NOT set on audit events. assert "source_log_type" not in event if expect_time is None: assert "_time" not in event else: assert event["_time"] == expect_time @pytest.mark.parametrize( "event, api_event_type, expect_time, expect_source_log_type", [ pytest.param( {"id": "c", "timestamp": "2099-01-01T12:34:56Z"}, "AUDIT_LOG", "2099-01-01T12:34:56Z", "compliance_audit_log", id="happy-audit_log-mapped", ), pytest.param( {"id": "c", "timestamp": "2099-01-02T08:00:00Z"}, "APP_LOG", "2099-01-02T08:00:00Z", "app_log", id="happy-app_log-mapped", ), # `_time` must come strictly from `timestamp` - `end_time` must NOT be used as a fallback. pytest.param( {"id": "c", "end_time": "2099-01-02T08:00:00Z"}, "APP_LOG", None, "app_log", id="bad-no-timestamp-no-_time-no-fallback-to-end_time", ), ], ) def test_enrich_compliance_event(event, api_event_type, expect_time, expect_source_log_type): """Compliance enrichment: `_time` from `timestamp`, `source_log_type` from the API event-type mapping, and `workspace_id` carried through onto every event.""" workspace_id = "FAKE_WORKSPACE_UUID" enrich_compliance_event(event, api_event_type, workspace_id) assert event["source_log_type"] == expect_source_log_type assert event["_event_type"] == api_event_type assert event["workspace_id"] == workspace_id if expect_time is None: assert "_time" not in event else: assert event["_time"] == expect_time def test_enrich_compliance_event_unknown_event_type_logs_info_and_falls_back(mocker): """Unknown api_event_type passes through lowercased AND logs at info so maintainers can spot new OpenAI compliance event types without DEBUG-level scraping.""" info_mock = mocker.patch.object(demisto, "info") event: dict[str, Any] = {"id": "FAKE_FUTURE_001", "timestamp": "2099-01-01T00:00:00Z"} enrich_compliance_event(event, api_event_type="FUTURE_NEW_TYPE", workspace_id="FAKE_WORKSPACE_UUID") assert event["source_log_type"] == "future_new_type" assert event["_event_type"] == "FUTURE_NEW_TYPE" assert event["workspace_id"] == "FAKE_WORKSPACE_UUID" assert info_mock.called, "Unknown api_event_type must log at info level." assert any("FUTURE_NEW_TYPE" in (c.args[0] if c.args else "") for c in info_mock.call_args_list) @pytest.mark.parametrize( "first_fetch_input, expected_delta", [ pytest.param("1 day", timedelta(days=1), id="happy-1-day"), pytest.param("3 days", timedelta(days=3), id="happy-3-days"), pytest.param("1 minute", timedelta(minutes=1), id="happy-1-minute"), pytest.param("1 minute ago", timedelta(minutes=1), id="happy-1-minute-ago-suffix"), pytest.param("7 days", timedelta(days=7), id="happy-7-days"), pytest.param("2 hours", timedelta(hours=2), id="happy-2-hours"), pytest.param("30 minutes", timedelta(minutes=30), id="happy-30-minutes"), ], ) def test_parse_first_fetch_to_datetime_happy_path(mocker, first_fetch_input, expected_delta): """`parse_first_fetch_to_datetime` returns a timezone-aware UTC datetime for valid inputs. Covers relative time expressions across a range of magnitudes (minutes -> days) and both bare ("1 minute") and "-ago" suffix ("1 minute ago") forms. """ error_mock = mocker.patch.object(demisto, "error") result = parse_first_fetch_to_datetime(first_fetch_input) assert isinstance(result, datetime) assert result.tzinfo is not None, "Returned datetime must be timezone-aware (UTC enforced)." expected = datetime.now(UTC) - expected_delta # Allow a small clock-drift window (test runtime + arg_to_datetime parse latency). assert abs((result - expected).total_seconds()) < 30 assert not error_mock.called, "Valid first_fetch must NOT emit demisto.error." @pytest.mark.parametrize( "bad_input", [ pytest.param("not-a-real-time", id="garbage-string"), pytest.param("definitely-not-a-time", id="another-garbage-string"), pytest.param("", id="empty-string"), pytest.param(" ", id="whitespace-only"), pytest.param("1 banana", id="number-with-nonsense-unit"), pytest.param("yesterday-ish", id="ambiguous-typo"), ], ) def test_parse_first_fetch_to_datetime_bad_input_falls_back_to_default(mocker, bad_input): """Unparseable input MUST fall back to `Config.DEFAULT_FIRST_FETCH`, never to a hardcoded window. This locks the regression where a typo silently widened the lookback window beyond the documented default. Whitespace, empty strings, and made-up unit names all must reach the fallback path. """ # Suppress the demisto.error stdout under pytest; the error-log contract is asserted by # test_parse_first_fetch_to_datetime_emits_error_log_on_bad_input. mocker.patch.object(demisto, "error") result = parse_first_fetch_to_datetime(bad_input) assert isinstance(result, datetime) assert result.tzinfo is not None, "Fallback must also be timezone-aware." # The fallback MUST equal Config.DEFAULT_FIRST_FETCH, never a hardcoded window. expected_fallback = arg_to_datetime(Config.DEFAULT_FIRST_FETCH, is_utc=True) assert expected_fallback is not None if expected_fallback.tzinfo is None: expected_fallback = expected_fallback.replace(tzinfo=UTC) drift = abs((result - expected_fallback).total_seconds()) assert drift < 30, f"Fallback drifted {drift:.1f}s from Config.DEFAULT_FIRST_FETCH - the bug regressed." def test_parse_first_fetch_to_datetime_emits_error_log_on_bad_input(mocker): """Unparseable input MUST emit a `demisto.error` so operators see the misconfiguration in standard log queries (separate concern from the fallback value itself).""" error_mock = mocker.patch.object(demisto, "error") parse_first_fetch_to_datetime("1 banana") assert error_mock.called, "Unparseable first_fetch must log at error level." # The error message must reference the bad input and the documented fallback. error_args = error_mock.call_args[0][0] assert "1 banana" in error_args assert Config.DEFAULT_FIRST_FETCH in error_args def test_parse_first_fetch_to_datetime_unix_seconds_format(): """Audit-stream call site: `int(dt.timestamp())` must produce a valid Unix-seconds integer.""" dt = parse_first_fetch_to_datetime("1 day") unix_seconds = int(dt.timestamp()) assert isinstance(unix_seconds, int) expected = int((datetime.now(UTC) - timedelta(days=1)).timestamp()) assert abs(unix_seconds - expected) < 30 def test_parse_first_fetch_to_datetime_iso_format(): """Compliance-stream call site must produce a clean ISO 8601 string. Verifies that `.replace(microsecond=0).strftime(Config.DATE_FORMAT)` yields a valid wire-format timestamp ending in `Z`. """ dt = parse_first_fetch_to_datetime("7 days") iso = dt.replace(microsecond=0).strftime(Config.DATE_FORMAT) assert isinstance(iso, str) assert iso.endswith("Z") parsed = datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) expected = datetime.now(UTC) - timedelta(days=7) assert abs((parsed - expected).total_seconds()) < 30 def test_selected_audit_enabled_and_compliance_event_types(): """Selection helpers should classify user-facing labels into Audit vs Compliance buckets.""" selected = [EventType.AUDIT, EventType.AUDIT_LOG, EventType.APP_LOG] assert selected_audit_enabled(selected) is True api_types = selected_compliance_event_types(selected) assert ComplianceEvent.AUDIT_LOG in api_types assert ComplianceEvent.APP_LOG in api_types # Negative: no Audit selected -> helper returns False; no Compliance -> empty list. assert selected_audit_enabled([EventType.APP_LOG]) is False assert selected_compliance_event_types([EventType.AUDIT]) == [] # endregion # region Event Collector tests - integration params parsing @pytest.mark.parametrize( "params, expected", [ pytest.param( { "url": "https://api.openai.com", "apikey": {"password": "FAKE_CHAT_KEY"}, "admin_api_key": {"password": "FAKE_ADMIN_KEY"}, "compliance_api_key": {"password": "FAKE_COMPLIANCE_KEY"}, "chatgpt_api_url": "https://fake-compliance.invalid", "model-freetext": "fake-model-x", "insecure": False, "proxy": False, "event_types_to_fetch": ["OpenAI Audit logs", "Compliance Audit"], }, { "base_url": "https://api.openai.com/", "api_key": "FAKE_CHAT_KEY", "admin_api_key": "FAKE_ADMIN_KEY", "compliance_api_key": "FAKE_COMPLIANCE_KEY", "chatgpt_base_url": "https://fake-compliance.invalid", "model": "fake-model-x", "verify": True, "proxy": False, }, id="happy-full-config-credentials-dict", ), pytest.param( { "apikey": "FAKE_RAW_STRING_KEY", # Not wrapped in {"password": ...} "model-select": "fake-model-y", "insecure": True, "proxy": True, "event_types_to_fetch": [], }, { "base_url": "https://api.openai.com/", # default "api_key": "FAKE_RAW_STRING_KEY", "admin_api_key": "", "compliance_api_key": "", "chatgpt_base_url": "https://api.chatgpt.com", # integration default "model": "fake-model-y", "verify": False, "proxy": True, }, id="happy-defaults-and-raw-string-key", ), ], ) def test_parse_integration_params_happy_paths(params, expected): """`parse_integration_params` extracts all fields and applies the documented defaults.""" config = parse_integration_params(params) for key, value in expected.items(): assert config[key] == value, f"Mismatch on '{key}': {config[key]!r} != {value!r}" @pytest.mark.parametrize( "params, expected_substr", [ pytest.param( { "event_types_to_fetch": ["NotAnEventType"], }, "Invalid event type", id="bad-unknown-event-type", ), pytest.param( { "admin_api_key": {"password": ""}, "event_types_to_fetch": ["OpenAI Audit logs"], }, "Admin API Key", id="bad-audit-without-admin-key", ), pytest.param( { "compliance_api_key": {"password": ""}, "event_types_to_fetch": ["Compliance Audit"], }, "Compliance API Key", id="bad-compliance-without-compliance-key", ), ], ) def test_parse_integration_params_bad_paths(params, expected_substr): """`parse_integration_params` raises informative `DemistoException` for invalid combos.""" with pytest.raises(DemistoException) as exc_info: parse_integration_params(params) assert expected_substr in str(exc_info.value) @pytest.mark.parametrize( "event_types, admin_key, compliance_key, expect_raises, expected_substr", [ pytest.param([], "", "", False, None, id="happy-empty-selection-no-validation"), pytest.param( ["OpenAI Audit logs", "Compliance Audit"], "admin", "compliance", False, None, id="happy-both-keys-both-groups", ), pytest.param(["OpenAI Audit logs"], "", "any", True, "Admin API Key", id="bad-audit-missing-admin-key"), pytest.param( ["Compliance Audit", "Apps"], "any", "", True, "Compliance API Key", id="bad-compliance-missing-compliance-key", ), ], ) def test_validate_event_types_credentials_correlation(event_types, admin_key, compliance_key, expect_raises, expected_substr): """Cross-validate selected event types vs. provided credentials.""" if expect_raises: with pytest.raises(DemistoException) as exc_info: validate_event_types_credentials_correlation( event_types_to_fetch=event_types, admin_api_key=admin_key, compliance_api_key=compliance_key, ) assert expected_substr in str(exc_info.value) else: # Should not raise. validate_event_types_credentials_correlation( event_types_to_fetch=event_types, admin_api_key=admin_key, compliance_api_key=compliance_key, ) # endregion # region Event Collector tests - parse_concatenated_json @pytest.mark.parametrize( "body, expected", [ pytest.param( '{"a":1,"nested":{"x":2}}{"b":2}\n{"c":3}', [{"a": 1, "nested": {"x": 2}}, {"b": 2}, {"c": 3}], id="happy-concatenated-objects", ), pytest.param( '{"a":1}\n {"b":2}\n\n{"c":3}\n', [{"a": 1}, {"b": 2}, {"c": 3}], id="happy-jsonl-with-whitespace", ), pytest.param( '{"a":1}"ignored"42[1,2,3]{"b":2}', [{"a": 1}, {"b": 2}], id="happy-non-dict-top-level-values-skipped", ), pytest.param("", [], id="bad-empty-body-returns-empty-list"), pytest.param(" \n ", [], id="bad-whitespace-only-returns-empty-list"), pytest.param('{"a":1}garbage', [{"a": 1}], id="bad-trailing-garbage-stops-parser-keeps-decoded"), ], ) def test_parse_concatenated_json(body, expected, capfd): """`parse_concatenated_json` splits a stream of concatenated JSON / JSONL into a list of dicts.""" # The "trailing garbage" case calls `demisto.error(...)` which writes to stdout in the test runtime. with capfd.disabled(): assert parse_concatenated_json(body) == expected def test_parse_concatenated_json_loads_fixture_file(): """End-to-end check using a synthetic concatenated-JSON body stored under test_data/.""" body = util_load_text("test_data/compliance_log_content_concatenated.txt") records = parse_concatenated_json(body) assert len(records) == 3 assert records[0]["actor"] == "FAKE_ACTOR_A" assert records[-1]["action"] == "dummy_action_three" # endregion # region Event Collector tests - Client guards & wire format @pytest.mark.parametrize( "client_kwargs, call_kwargs, expected_substr", [ pytest.param( {"admin_api_key": ""}, {}, "Admin API Key", id="bad-audit-without-admin-key", ), ], ) def test_get_audit_logs_guards(client_kwargs, call_kwargs, expected_substr): """`Client.get_audit_logs` must refuse to fire without an Admin API key.""" client = _make_client(**client_kwargs) with pytest.raises(DemistoException) as exc_info: client.get_audit_logs(**call_kwargs) assert expected_substr in str(exc_info.value) def test_get_audit_logs_uses_cursor_when_present(mocker): """Happy path: when `after=` is provided, the request must NOT include `effective_at[gt]`.""" client = _make_client() response = util_load_json("test_data/audit_logs_page_response.json") http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=response) result = client.get_audit_logs(after="FAKE_AUDIT_CURSOR_PREV", effective_at_gt=1000) assert result["last_id"] == "FAKE_AUDIT_CURSOR_AAAA" request_params = http_mock.call_args.kwargs["params"] assert request_params["after"] == "FAKE_AUDIT_CURSOR_PREV" # When a cursor is present, the time-seed must be ignored (cursor wins). assert "effective_at[gt]" not in request_params def test_get_audit_logs_uses_time_seed_on_first_call(mocker): """First-ever call (no cursor) must seed the request with `effective_at[gt]`.""" client = _make_client() http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False}) client.get_audit_logs(after=None, effective_at_gt=1234567890) request_params = http_mock.call_args.kwargs["params"] assert request_params["effective_at[gt]"] == 1234567890 assert "after" not in request_params @pytest.mark.parametrize( "client_kwargs, call_kwargs, expected_substr", [ pytest.param( {"compliance_api_key": ""}, {"workspace_id": "FAKE_WORKSPACE_ID", "event_types": ["APP_LOG"], "after": "2099-01-01T00:00:00Z"}, "Compliance API Key", id="bad-no-compliance-key", ), pytest.param( {"compliance_api_key": "FAKE_COMPLIANCE_KEY"}, {"workspace_id": "", "event_types": ["APP_LOG"], "after": "2099-01-01T00:00:00Z"}, "Workspace ID", id="bad-no-workspace-id", ), ], ) def test_list_compliance_logs_guards(client_kwargs, call_kwargs, expected_substr): """`Client.list_compliance_logs` must refuse to fire without both a key and a workspace.""" client = _make_client(**client_kwargs) with pytest.raises(DemistoException) as exc_info: client.list_compliance_logs(**call_kwargs) assert expected_substr in str(exc_info.value) @pytest.mark.parametrize( "upstream, expected_data, expected_last_end_time", [ pytest.param( { "data": [{"id": "FAKE_LISTING_001", "end_time": "2099-01-02T00:00:00Z"}], "has_more": False, "last_end_time": "2099-01-02T00:00:00Z", }, [{"id": "FAKE_LISTING_001", "end_time": "2099-01-02T00:00:00Z"}], "2099-01-02T00:00:00Z", id="happy-dict-shape-with-last_end_time", ), pytest.param( [{"id": "FAKE_LISTING_002", "end_time": "2099-02-01T00:00:00Z"}], [{"id": "FAKE_LISTING_002", "end_time": "2099-02-01T00:00:00Z"}], None, id="happy-legacy-bare-list-shape-normalized", ), pytest.param(None, [], None, id="bad-non-list-non-dict-response-normalized-empty"), pytest.param({}, [], None, id="bad-empty-dict-response-normalized-empty"), ], ) def test_list_compliance_logs_normalizes_response(mocker, upstream, expected_data, expected_last_end_time): """`list_compliance_logs` must normalize any upstream shape into `{data, last_end_time}`.""" client = _make_client() mocker.patch.object(OpenAiClient, "_http_request", return_value=upstream) result = client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["APP_LOG"], after="2099-01-01T00:00:00Z") assert result["data"] == expected_data assert result.get("last_end_time") == expected_last_end_time def test_get_compliance_log_content_parses_concatenated_json(mocker): """`get_compliance_log_content` fetches as text and parses concatenated JSON into a list of dicts.""" client = _make_client() body = util_load_text("test_data/compliance_log_content_concatenated.txt") mocker.patch.object(OpenAiClient, "_http_request", return_value=body) records = client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LISTING_002") assert len(records) == 3 assert all(isinstance(r, dict) for r in records) assert records[0]["action"] == "dummy_action_one" def test_get_compliance_log_content_requires_compliance_key(): """Bad path: missing Compliance API Key must raise before any HTTP request.""" client = _make_client(compliance_api_key="") with pytest.raises(DemistoException) as exc_info: client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LISTING_002") assert "Compliance API Key" in str(exc_info.value) # region Retry policy tests # ============================================================================= # Verify that event-collector HTTP calls forward `Config.RETRY_POLICY` to # `_http_request`, while chat-completion stays fail-fast (no retry). Without # this contract, transient OpenAI 5xx/429 errors silently fail the fetch and # the UI shows an opaque "Error pulling at