OpenAi ChatGPT v3
This integration assists security professionals with security investigations, threat hunting, and anomaly detection by leveraging OpenAI GPT models' natural language conversation capabilities.
Messaging and Conferencing · OpenAI
Details
| ID | OpenAi ChatGPT v3 |
|---|---|
| Provider | OpenAI |
| Category | Messaging and Conferencing |
| From Version | 6.0.0 |
| Docker Image | demisto/parse-emails:0.1.48.10120494 |
| Supported Modules | Agentix XSIAM |
README
OpenAI GPT
Instance Configuration
-
Generate an API Key
- Sign up or log in to OpenAI developer platform.
- Generate a new API key at OpenAI developer platform - api-keys.
-
Choose a GPT model to interact with
-
This integration supports only the ‘Chat Completions’ endpoint. Therefore, you can only configure models that support this endpoint (https://api.openai.com/v1/chat/completions).
-
For tasks requiring deep understanding and extensive inputs, opt for more advanced models (e.g. gpt-4). These models offer a larger context window, allowing them to process bigger documents, and provide more refined and comprehensive responses.
The more elementary models (e.g. gpt-3.5) often provide shallower answers and input analysis.
Refer to Models overview for more information.
-
-
Text generation setting (Optional)
- max-tokens: The maximum number of tokens that can be generated for the response. (Allows controlling tokens’ consumption). Default: unset.
- temperature: Sets the randomness in responses. Lower values (closer to 0) produce more deterministic and consistent outputs, while higher values (up to 2) increase randomness and variety. It is generally recommended altering this or top_p but not both. Default: 1.
- top_p: Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Lower values (closer to 0) result in more focused outputs, while higher values (closer to 1) increase diversity. It is generally recommended altering this or temperature but not both. Default: unset.
-
Event Collector — Generate API Keys
- Admin API Key (required for
OpenAI Audit logs): generate from the OpenAI Platform admin console. Used to call/v1/organization/audit_logs. - Compliance API Key (required for any Compliance event type): generate from the ChatGPT Platform. Used to call
/v1/compliance/workspaces/{workspace_id}/.... - Workspace ID (required for any Compliance event type): the UUID of the compliance workspace whose events you want to collect.
- Admin API Key (required for
-
Event Collector — Select event types to fetch
Toggle Fetch events, then select one or more Events types to fetch:
User-facing label Source Required credentials OpenAI Audit logs OpenAI Platform — Admin API Admin API Key Conversation Messages ChatGPT Platform — Compliance API Compliance API Key + Workspace ID Apps ChatGPT Platform — Compliance API Compliance API Key + Workspace ID Apps Auth ChatGPT Platform — Compliance API Compliance API Key + Workspace ID Compliance Audit ChatGPT Platform — Compliance API Compliance API Key + Workspace ID Auth ChatGPT Platform — Compliance API Compliance API Key + Workspace ID Codex ChatGPT Platform — Compliance API Compliance API Key + Workspace ID ChatGPT ChatGPT Platform — Compliance API Compliance API Key + Workspace ID Codex Security ChatGPT Platform — Compliance API Compliance API Key + Workspace ID Workspace Agents ChatGPT Platform — Compliance API Compliance API Key + Workspace ID Selecting an event type without its matching credentials raises an informative error at instance test time, naming the missing parameter.
-
Event Collector — Datasets
Each Event Collector stream lands in its own Cortex dataset:
Stream Vendor Product Dataset OpenAI Audit logs openaichatgpt_auditopenai_chatgpt_audit_rawCompliance logs (all) openaichatgpt_complianceopenai_chatgpt_compliance_raw -
Event Collector — Tuning (Optional)
Parameter Default Description Maximum number of OpenAI Audit events per fetch 1000 Cap on Audit events ingested per fetch cycle. Maximum number of Compliance events per fetch 900 Cap on Compliance events ingested per fetch cycle. Events Fetch Interval 1 minute How often the scheduled fetch runs. ChatGPT Server URL https://api.chatgpt.comBase URL of the ChatGPT Compliance API. Override only for non-default tenants. -
Click ‘Test’
Commands
You can execute these commands from the Cortex XSOAR CLI, as part of an automation, or in a playbook.
gpt-send-message
Send a message as a prompt to the GPT model.
!gpt-send-message message="<MESSAGE_TEXT>"
Input
| Argument Name | Description | Required |
|---|---|---|
| message | The message to send to the GPT model wrapped with quotes. | Yes |
| reset_conversation_history | Whether to reset conversation history or keep it as context for the sent message. (Conversation history is not reset by default). | No |
| max_tokens | The maximum number of tokens that can be generated for the response. Overrides text generation setting for the specific message sent. | No |
| temperature | Sets the randomness in responses. Overrides text generation setting for the specific message sent. | No |
| top_p | Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Overrides text generation setting for the specific message sent. | No |
gpt-check-email-body
Check email body for possible security issues.
!gpt-check-email-body entryId="<ENTRY_ID_OF_UPLOADED_EML_FILE>"
Input
| Argument Name | Description | Required |
|---|---|---|
| entryId | Entry ID of an uploaded .eml file from the context window. | Yes |
| additionalInstructions | Provide additional instructions for the GPT model when analyzing the email body. | No |
| max_tokens | The maximum number of tokens that can be generated for the response. Overrides text generation setting for the specific message sent. | No |
| temperature | Sets the randomness in responses. Overrides text generation setting for the specific message sent. | No |
| top_p | Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Overrides text generation setting for the specific message sent. | No |
gpt-check-email-header
Check email body for possible security issues.
!gpt-check-email-header entryId="<ENTRY_ID_OF_UPLOADED_EML_FILE>"
Input
| Argument Name | Description | Required |
|---|---|---|
| entryId | Entry ID of an uploaded .eml file from context window. | Yes |
| additionalInstructions | Provide additional instructions for the GPT model when analyzing the email headers. | No |
| max_tokens | The maximum number of tokens that can be generated for the response. Overrides text generation setting for the specific message sent. | No |
| temperature | Sets the randomness in responses. Overrides text generation setting for the specific message sent. | No |
| top_p | Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Overrides text generation setting for the specific message sent. | No |
gpt-analyze-email-header
Analyze email headers for potential security issues using the OpenAI Responses API. This command uses the Responses API which is recommended for all new projects (instead of gpt-check-email-header which uses the Chat Completions API).
!gpt-analyze-email-header entry_id="3@123" additional_instructions="Pay close attention to SPF/DKIM."
Input
| Argument Name | Description | Required |
|---|---|---|
| entry_id | Entry ID of an uploaded .eml file. | Yes |
| additional_instructions | Additional instructions or security issue to focus on. Substituted into the prompt template. | No |
| max_tokens | The maximum number of tokens that can be generated for the response. Maps internally to the API body field max_output_tokens. |
No |
| temperature | Sets the randomness in responses. Lower values (closer to 0) produce more deterministic and consistent outputs, while higher values (up to 2) increase randomness and variety. | No |
| top_p | Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Range 0–1. | No |
| reasoning_effort | Reasoning effort level for reasoning models (o1, o3, o4, gpt-5). Controls how much thinking the model does before responding. Possible values: low, medium, high. |
No |
Context Output
| Path | Type | Description |
|---|---|---|
| OpenAiChatGPTV3.Response | Unknown | The conversation state including the response_id. |
| OpenAiChatGPTV3.Response.user | String | The prompt sent to the model. |
| OpenAiChatGPTV3.Response.assistant | String | The assistant response text. |
| OpenAiChatGPTV3.Response.response_id | String | The OpenAI response ID. |
Human Readable Output
Two war-room entries are produced:
- A table of the parsed email headers.
- The AI verdict followed by a token-usage table. A Reasoning tokens row appears in the usage table when a reasoning model is used.
gpt-analyze-email-body
Analyze email body for potential security risks using the OpenAI Responses API. This command uses the Responses API which is recommended for all new projects (instead of gpt-check-email-body which uses the Chat Completions API).
!gpt-analyze-email-body entry_id="3@123"
Input
| Argument Name | Description | Required |
|---|---|---|
| entry_id | Entry ID of an uploaded .eml file. | Yes |
| additional_instructions | Additional instructions or security issue to focus on. Substituted into the prompt template. | No |
| max_tokens | The maximum number of tokens that can be generated for the response. Maps internally to the API body field max_output_tokens. |
No |
| temperature | Sets the randomness in responses. Lower values (closer to 0) produce more deterministic and consistent outputs, while higher values (up to 2) increase randomness and variety. | No |
| top_p | Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Range 0–1. | No |
| reasoning_effort | Reasoning effort level for reasoning models (o1, o3, o4, gpt-5). Controls how much thinking the model does before responding. Possible values: low, medium, high. |
No |
Context Output
| Path | Type | Description |
|---|---|---|
| OpenAiChatGPTV3.Response | Unknown | The conversation state including the response_id. |
| OpenAiChatGPTV3.Response.user | String | The prompt sent to the model. |
| OpenAiChatGPTV3.Response.assistant | String | The assistant response text. |
| OpenAiChatGPTV3.Response.response_id | String | The OpenAI response ID. |
Human Readable Output
Two war-room entries are produced:
- A table of the parsed email body (text and HTML).
- The AI verdict followed by a token-usage table. A Reasoning tokens row appears in the usage table when a reasoning model is used.
gpt-create-soc-email-template
Create an email template out of the conversation context to be sent from the SOC.
!gpt-create-soc-email-template
Input
| Argument Name | Description | Required |
|---|---|---|
| additionalInstructions | Provide additional instructions for the GPT model when analyzing the email headers. | No |
| max_tokens | The maximum number of tokens that can be generated for the response. Overrides text generation setting for the specific message sent. | No |
| temperature | Sets the randomness in responses. Overrides text generation setting for the specific message sent. | No |
| top_p | Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Overrides text generation setting for the specific message sent. | No |
gpt-draft-soc-email
Draft a SOC email template using the OpenAI Responses API. This command uses the Responses API which is recommended for all new projects (instead of gpt-create-soc-email-template which uses the Chat Completions API). Consumes prior conversation context by design (e.g. from a preceding gpt-analyze-email-body call).
Cortex XSOAR sequence (typical phishing flow)
!gpt-analyze-email-body entry_id="3@123"
…assistant returns analysis…
!gpt-draft-soc-email additional_instructions="Notify the user the email was quarantined."
Input
| Argument Name | Description | Required |
|---|---|---|
| additional_instructions | Specific issue or focus area to weave into the template. Substituted into the prompt template. | No |
| max_tokens | The maximum number of tokens that can be generated for the response. Maps internally to the API body field max_output_tokens. |
No |
| temperature | Sets the randomness in responses. Lower values (closer to 0) produce more deterministic and consistent outputs, while higher values (up to 2) increase randomness and variety. | No |
| top_p | Enables nucleus sampling where only the top ‘p’ percent of probable tokens are considered. Range 0–1. | No |
| reasoning_effort | Reasoning effort level for reasoning models (o1, o3, o4, gpt-5). Controls how much thinking the model does before responding. Possible values: low, medium, high. |
No |
Context Output
| Path | Type | Description |
|---|---|---|
| OpenAiChatGPTV3.Response | Unknown | The conversation state including the response_id. |
| OpenAiChatGPTV3.Response.user | String | The prompt sent to the model. |
| OpenAiChatGPTV3.Response.assistant | String | The assistant response text. |
| OpenAiChatGPTV3.Response.response_id | String | The OpenAI response ID. |
Human Readable Output
Two war-room entries are produced:
- The SOC email template context output (
replace_existing=True— running twice overwrites the previous draft). - The AI-generated template followed by a token-usage table. A Reasoning tokens row appears in the usage table when a reasoning model is used.
openai-get-events
Manually fetch a bounded batch of Audit and/or Compliance events for development/debugging. Does NOT advance the persisted last_run cursor, so it is safe to run against production tenants. Use should_push_events=true to additionally ingest the fetched events into the matching Cortex dataset.
Base Command
openai-get-events
Input
| Argument Name | Description | Required |
|---|---|---|
| event_type | The event type(s) to fetch. Comma-separated list. Possible values: OpenAI Audit logs, Conversation Messages, Apps, Apps Auth, Compliance Audit, Auth, Codex, ChatGPT, Codex Security, Workspace Agents. Defaults to the values configured in the integration parameters. |
No |
| limit | Maximum number of events to return per stream. Default: 50. |
No |
| start_time | Lookback start time for the fetch. Supports ISO 8601 or relative time (e.g., 3 days ago, 2099-01-01T00:00:00Z). |
No |
| should_push_events | If true, the command also pushes the retrieved events to Cortex (Audit -> openai_chatgpt_audit_raw, Compliance -> openai_chatgpt_compliance_raw). Possible values: true, false. Default: false. |
No |
Context Output
| Path | Type | Description |
|---|---|---|
| OpenAI.Event.id | String | The unique identifier of the event. |
| OpenAI.Event._event_type | String | The upstream event_type for Compliance events. Left empty for Audit events. |
| OpenAI.Event.source_log_type | String | The source log type used by downstream parsing rules. |
| OpenAI.Event._time | Date | The event timestamp in ISO 8601 format. |
Human Readable Output
OpenAI GPT Events
id _event_type source_log_type _time FAKE_AUDIT_EVENT_001 openai_audit_logs 2099-01-01T00:00:00Z FAKE_LISTING_002 AUDIT_LOG compliance_audit_log 2099-01-02T00:00:00Z
gpt-create-response
Sends a message to the OpenAI Responses API and receives the generated response. This command uses the Responses API which is recommended for all new projects (instead of gpt-send-message which uses the Chat Completions API). Supports multi-turn conversations via previous_response_id, reasoning effort control for o-series and gpt-5 models, and background execution.
Base Command
gpt-create-response
Input
| Argument Name | Description | Required |
|---|---|---|
| message | The user message to send. | Required |
| reset_conversation_history | Whether to discard the existing conversation context and start fresh. Possible values are: yes, no. Default is no. | Optional |
| max_tokens | The maximum number of output tokens. Falls back to instance config. Maps internally to the API body field max_output_tokens. | Optional |
| temperature | The randomness level in responses. Falls back to instance config. Range 0-2. Lower values produce more deterministic outputs, while higher values increase variety. | Optional |
| top_p | The nucleus sampling threshold. Falls back to instance config. Range 0-1. Lower values result in more focused outputs, while higher values increase diversity. | Optional |
| reasoning_effort | The reasoning effort level. Honored only for reasoning families (o1, o3, o4, gpt-5); silently dropped on others. Default medium. Possible values are: none, minimal, low, medium, high, xhigh. | Optional |
| background | Whether to run the model response in the background. When true, the command uses polling to wait for the response to complete. Possible values are: true, false. | Optional |
| compact_threshold | The token threshold at which compaction should be triggered for this entry. Minimum 1000. | Optional |
| model | The model to use. Use the gpt-list-models command to see available models. Falls back to instance config. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| OpenAiChatGPTV3.Response | Unknown | The conversation state, which includes the response_id for multi-turn continuity. |
| OpenAiChatGPTV3.Response.user | String | The user message sent. |
| OpenAiChatGPTV3.Response.assistant | String | The assistant response text. |
| OpenAiChatGPTV3.Response.response_id | String | The OpenAI response ID used for multi-turn conversation continuity. |
gpt-list-models
Lists all models available to the configured API key. Lets users discover models per their actual API-key tier without redeploying the integration when OpenAI ships new ones.
Base Command
gpt-list-models
Input
| Argument Name | Description | Required |
| — | — | — |
Context Output
| Path | Type | Description |
|---|---|---|
| OpenAiChatGPTV3.Model.Id | String | The model identifier (e.g., gpt-4, gpt-3.5-turbo). |
| OpenAiChatGPTV3.Model.Created | Number | The Unix timestamp when the model was created. |
| OpenAiChatGPTV3.Model.OwnedBy | String | The organization or entity that owns the model. |
gpt-create-moderation
Runs text or an image through the OpenAI Moderations API and returns per-category flagging results. Exactly one of text, entry_id, or image_url must be provided.
Base Command
gpt-create-moderation
Input
| Argument Name | Description | Required |
|---|---|---|
| text | A comma-separated list of text strings to moderate. Exactly one of text, entry_id, or image_url must be provided. | Optional |
| entry_id | The war-room entry ID of an uploaded image file. The file is base64-encoded internally and posted as a data URL. Exactly one of text, entry_id, or image_url must be provided. | Optional |
| image_url | The publicly reachable HTTP(S) URL of an image (limited to 20 MB). Exactly one of text, entry_id, or image_url must be provided. | Optional |
| model | The moderation model to use. Possible values are: omni-moderation-latest, omni-moderation-2024-09-26. Default is omni-moderation-latest. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| OpenAiChatGPTV3.Moderation.Input.input_type | String | The type of input that was moderated (text, image, or image_url). |
| OpenAiChatGPTV3.Moderation.Input.input_value | String | The value of the input that was moderated. |
| OpenAiChatGPTV3.Moderation.Flagged | Boolean | Whether the content was flagged by the moderation model. |
| OpenAiChatGPTV3.Moderation.Categories | Unknown | The object of boolean values indicating which categories were flagged. |
| OpenAiChatGPTV3.Moderation.CategoryScores | Unknown | The object of float values indicating the confidence score for each category. |
<~PLATFORM>
License Requirements
The following configuration parameters require the Cortex XSIAM license:
- Fetch events
</~PLATFORM>
Configuration parameters
url— Server URL (required)chatgpt_api_url— ChatGPT Server URLapikey—admin_api_key— Admin API Keycompliance_api_key— Compliance API Keyworkspace_id— Workspace IDmodel-select— Modelmodel-freetext— Model (Optional - overrides selected choice)max_tokens— Max tokenstemperature— Temperaturetop_p— Top Pinsecure— Trust any certificate (not secure)proxy— Use system proxy settingsisFetchEvents— Fetch eventsevent_types_to_fetch— Events types to fetchaudit_max_fetch— Maximum number of OpenAI Audit events per fetchcompliance_max_fetch— Maximum number of Compliance events per fetcheventFetchInterval— Events Fetch Interval
Commands (11)
-
gpt-analyze-email-bodyAnalyzes email body for potential security risks using the OpenAI Responses API. This is the Responses-API counterpart of gpt-check-email-body (which uses the Chat Completions API).
-
gpt-analyze-email-headerAnalyzes email headers for potential security issues using the OpenAI Responses API. This is the Responses-API counterpart of gpt-check-email-header (which uses the Chat Completions API).
-
gpt-check-email-bodyChecks the email body for possible security issues. Enables you to ask subsequent questions on the provided information using the 'gpt-send-message' command, and resets the conversation context by default.
-
gpt-check-email-headerChecking email header for possible security issues. It is possible to keep asking questions on the provided info using 'gpt-send-message'. Resets conversation context by default.
-
gpt-create-moderationRuns text or an image through the OpenAI Moderations API and returns per-category flagging results. Exactly one of text, entry_id, or image_url must be provided.
-
gpt-create-responseSends a message to the OpenAI Responses API and receives the generated response. This command uses the Responses API which is recommended for all new projects (instead of gpt-send-message which uses the Chat Completions API). Supports multi-turn conversations via previous_response_id, reasoning effort control for o-series and gpt-5 models, and background execution.
-
gpt-create-soc-email-templateCreate an email template out of the conversation context to be sent from the SOC.
-
gpt-draft-soc-emailDrafts a SOC email template using the OpenAI Responses API. This command uses the Responses API which is recommended for all new projects (instead of gpt-create-soc-email-template which uses the Chat Completions API). Consumes prior conversation context by design (e.g. from a preceding gpt-analyze-email-body call).
-
gpt-list-modelsLists all models available to the configured API key. Lets users discover models per their actual API-key tier without redeploying the integration when OpenAI ships new ones.
-
gpt-send-messageSend a plain message to the selected GPT model and receive the generated response.
-
openai-get-eventsManually retrieves events from OpenAI. Use this command for development and debugging only, as it may produce duplicate events, exceed API rate limits, or disrupt the fetch mechanism.
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 <time>" with no actionable details. # ============================================================================= def test_retry_policy_values_are_sensible(): """The retry policy constant must use the expected values - any drift here is a behaviour change operators rely on (number of retries, which statuses retry, exponential backoff factor).""" policy = module.Config.RETRY_POLICY assert policy["retries"] == 3 assert set(policy["status_list_to_retry"]) == {429, 500, 502, 503, 504} assert policy["backoff_factor"] == 2 # `raise_on_status=True` is what converts an exhausted retry into a `DemistoException` # rather than silently returning the last error response body. assert policy["raise_on_status"] is True @pytest.mark.parametrize( "method_name, call_kwargs", [ pytest.param( "get_audit_logs", {"after": "FAKE_AUDIT_CURSOR", "effective_at_gt": None}, id="happy-audit-logs-forwards-retry-policy", ), pytest.param( "list_compliance_logs", {"workspace_id": "FAKE_WORKSPACE_ID", "event_types": ["APP_LOG"], "after": "2099-01-01T00:00:00Z"}, id="happy-compliance-list-forwards-retry-policy", ), pytest.param( "get_compliance_log_content", {"workspace_id": "FAKE_WORKSPACE_ID", "log_id": "FAKE_LOG_001"}, id="happy-compliance-content-forwards-retry-policy", ), ], ) def test_event_collector_calls_forward_retry_policy(mocker, method_name, call_kwargs): """Good path: every event-collector `_http_request` call must include the RETRY_POLICY kwargs. This is asserted at the contract layer (the kwargs forwarded into `_http_request`) rather than by simulating a 5xx and counting retries, which would couple the test to `urllib3.Retry`'s internals and break across CI image upgrades. """ client = _make_client() http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value="{}") getattr(client, method_name)(**call_kwargs) forwarded = http_mock.call_args.kwargs for key, expected in module.Config.RETRY_POLICY.items(): assert forwarded.get(key) == expected, f"{method_name}: missing/wrong '{key}' kwarg passed to _http_request" def test_chat_completions_does_not_forward_retry_policy(mocker): """Bad path: chat-completion is interactive and intentionally NOT retried so the user sees transient errors immediately in the war room rather than waiting through backoffs.""" client = _make_client() http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"choices": [{"message": {"content": "hi"}}]}) client.get_chat_completions(chat_context=[{"role": "user", "content": "ping"}], completion_params={}) forwarded = http_mock.call_args.kwargs for key in module.Config.RETRY_POLICY: assert key not in forwarded, f"chat-completions must NOT forward '{key}' (interactive commands must fail fast)" EXPECTED_NEW_API_RETRY_POLICY = { "retries": 3, "status_list_to_retry": [429, 500, 502, 503, 504], "backoff_factor": 5, } @pytest.mark.parametrize( "method_name, call_kwargs", [ pytest.param( "list_models", {}, id="list-models-forwards-retry-policy", ), pytest.param( "create_moderation", {"body": {"model": "omni-moderation-latest", "input": [{"type": "text", "text": "test"}]}}, id="create-moderation-forwards-retry-policy", ), pytest.param( "create_response", {"body": {"model": "gpt-4", "input": "test"}}, id="create-response-forwards-retry-policy", ), pytest.param( "get_response", {"response_id": "resp_FAKE_001"}, id="get-response-forwards-retry-policy", ), ], ) def test_new_api_calls_forward_retry_policy(mocker, method_name, call_kwargs): """Every new API method (Models, Moderations, Responses) must include retry kwargs with retries=3, status_list_to_retry=[429, 500, 502, 503, 504], backoff_factor=5. Asserted at the contract layer (kwargs forwarded into ``_http_request``) rather than by simulating a 5xx and counting retries. """ client = _make_client() http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value="{}") getattr(client, method_name)(**call_kwargs) forwarded = http_mock.call_args.kwargs for key, expected in EXPECTED_NEW_API_RETRY_POLICY.items(): assert forwarded.get(key) == expected, ( f"{method_name}: missing/wrong '{key}' kwarg passed to _http_request " f"(expected={expected!r}, got={forwarded.get(key)!r})" ) # endregion def test_send_events_routes_to_correct_dataset(mocker): """`send_events` must call `send_events_to_xsiam` with the requested vendor + product.""" client = _make_client() sender = mocker.patch.object(module, "send_events_to_xsiam") events = [{"id": "e1"}] client.send_events(events, product=module.Config.PRODUCT_AUDIT) sender.assert_called_once_with(events=events, vendor=module.Config.VENDOR, product=module.Config.PRODUCT_AUDIT) def test_send_events_skips_when_empty(mocker): """`send_events` must NOT call the XSIAM sender when there are no events.""" client = _make_client() sender = mocker.patch.object(module, "send_events_to_xsiam") client.send_events([], product=module.Config.PRODUCT_COMPLIANCE) sender.assert_not_called() # endregion # region Event Collector tests - fetch_audit_logs def test_fetch_audit_logs_returns_events_and_advances_cursor(mocker): """Happy path: a single page is fetched, events enriched, and the API `last_id` cursor persisted.""" client = _make_client() response = util_load_json("test_data/audit_logs_page_response.json") mocker.patch.object(OpenAiClient, "_http_request", return_value=response) events, updates = fetch_audit_logs(client=client, last_run={}, max_fetch=10, first_fetch="1 day") assert [e["id"] for e in events] == ["FAKE_AUDIT_EVENT_001", "FAKE_AUDIT_EVENT_002"] # Audit events are routed to a dedicated dataset; `source_log_type` is intentionally NOT set. assert all("source_log_type" not in e for e in events) # The cursor returned by the API is what gets persisted - verbatim. assert updates[LastRunKey.AUDIT_AFTER] == "FAKE_AUDIT_CURSOR_AAAA" # Audit no longer keeps an explicit ID-list / time HWM in last_run. assert "audit_effective_at" not in updates assert "audit_last_ids" not in updates def test_fetch_audit_logs_resumes_from_stored_cursor(mocker): """Happy path: stored cursor is forwarded as `after=`; first-fetch time-seed is NOT used.""" client = _make_client() response = { "data": [{"id": "FAKE_AUDIT_EVENT_003", "effective_at": 1200}], "has_more": False, "last_id": "FAKE_AUDIT_CURSOR_BBBB", } http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=response) last_run = {LastRunKey.AUDIT_AFTER: "FAKE_AUDIT_CURSOR_AAAA"} events, updates = fetch_audit_logs(client=client, last_run=last_run, max_fetch=10, first_fetch="1 day") assert [e["id"] for e in events] == ["FAKE_AUDIT_EVENT_003"] assert updates[LastRunKey.AUDIT_AFTER] == "FAKE_AUDIT_CURSOR_BBBB" request_params = http_mock.call_args.kwargs.get("params", {}) assert request_params.get("after") == "FAKE_AUDIT_CURSOR_AAAA" assert "effective_at[gt]" not in request_params def test_fetch_audit_logs_empty_first_fetch_persists_seed(mocker): """Bad path: first-ever fetch returns empty - no events, but the time-seed is persisted so the next cycle replays the same lookback window instead of sliding it with `now()`.""" client = _make_client() mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False}) mocker.patch( "OpenAiChatGPTV3.parse_first_fetch_to_datetime", return_value=datetime.fromtimestamp(1700000000, tz=UTC), ) debug_mock = mocker.patch.object(demisto, "debug") events, updates = fetch_audit_logs(client=client, last_run={}, max_fetch=10, first_fetch="1 minute ago") assert events == [] # The seed MUST be persisted; the cursor key MUST NOT appear. assert updates == {LastRunKey.AUDIT_FIRST_FETCH_SEED: 1700000000} assert LastRunKey.AUDIT_AFTER not in updates # Operators must see a clear log line so first-empty-fetch is discoverable in standard log queries. assert any("persisting seed=1700000000" in (c.args[0] if c.args else "") for c in debug_mock.call_args_list) def test_fetch_audit_logs_replays_stored_seed_without_recomputing(mocker): """The stored seed is replayed verbatim across empty cycles; `parse_first_fetch_*` is not called again.""" client = _make_client() http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False}) parse_mock = mocker.patch("OpenAiChatGPTV3.parse_first_fetch_to_datetime") debug_mock = mocker.patch.object(demisto, "debug") last_run = {LastRunKey.AUDIT_FIRST_FETCH_SEED: 1700000000} events, updates = fetch_audit_logs(client=client, last_run=last_run, max_fetch=10, first_fetch="1 minute ago") assert events == [] # Seed already present - do NOT recompute, do NOT re-persist (keeps last_run idempotent). parse_mock.assert_not_called() assert updates == {} # The same seed was forwarded as `effective_at[gt]` on the wire. request_params = http_mock.call_args.kwargs.get("params", {}) assert request_params.get("effective_at[gt]") == 1700000000 # A "replaying ... seed" debug line must be emitted so operators can correlate sustained empty fetches. assert any("Replaying persisted first-fetch seed" in (c.args[0] if c.args else "") for c in debug_mock.call_args_list) def test_fetch_audit_logs_real_cursor_clears_stored_seed(mocker): """Once a real `last_id` arrives, the seed is cleared so it never reappears in future runs.""" client = _make_client() response = { "data": [{"id": "FAKE_AUDIT_EVENT_001", "effective_at": 1700000001}], "has_more": False, "last_id": "FAKE_AUDIT_CURSOR_AAAA", } mocker.patch.object(OpenAiClient, "_http_request", return_value=response) last_run = {LastRunKey.AUDIT_FIRST_FETCH_SEED: 1700000000} events, updates = fetch_audit_logs(client=client, last_run=last_run, max_fetch=10, first_fetch="1 minute ago") assert [e["id"] for e in events] == ["FAKE_AUDIT_EVENT_001"] assert updates[LastRunKey.AUDIT_AFTER] == "FAKE_AUDIT_CURSOR_AAAA" # Seed must be explicitly cleared (None) so it is dropped from `last_run` on merge. assert updates[LastRunKey.AUDIT_FIRST_FETCH_SEED] is None # endregion # region Event Collector tests - fetch_compliance_logs def test_fetch_compliance_logs_two_step_flow(mocker): """Happy path: list endpoint -> per-id content fetch -> per-record enrichment + cursor persistence.""" client = _make_client() listing_response = util_load_json("test_data/compliance_listing_response.json") contents = { "FAKE_LISTING_001": [{"id": "FAKE_LISTING_001", "timestamp": "2099-01-01T00:00:00Z", "actor": "FAKE_ACTOR_A"}], "FAKE_LISTING_002": [ {"id": "FAKE_LISTING_002", "timestamp": "2099-01-02T00:00:00Z", "actor": "FAKE_ACTOR_B"}, {"id": "FAKE_LISTING_002", "timestamp": "2099-01-02T00:00:01Z", "actor": "FAKE_ACTOR_C"}, ], } mocker.patch.object(OpenAiClient, "list_compliance_logs", return_value=listing_response) mocker.patch.object(OpenAiClient, "get_compliance_log_content", side_effect=lambda workspace_id, log_id: contents[log_id]) events, updates = fetch_compliance_logs( client=client, workspace_id="FAKE_WORKSPACE_ID", api_event_types=[ComplianceEvent.AUDIT_LOG, ComplianceEvent.APP_LOG], last_run={}, max_fetch=100, first_fetch="1 day", ) # FAKE_LISTING_001 -> 1 record, FAKE_LISTING_002 -> 2 records (JSONL-shaped content) = 3 events total. assert len(events) == 3 assert events[0]["source_log_type"] == SourceLogType.COMPLIANCE_AUDIT_LOG assert events[1]["source_log_type"] == SourceLogType.APP_LOG # `workspace_id` is propagated from the fetch call into every enriched event. assert all(e["workspace_id"] == "FAKE_WORKSPACE_ID" for e in events) # `last_end_time` is read from the listing response, NOT computed from individual entries. assert updates[LastRunKey.COMPLIANCE_LAST_END_TIME] == "2099-01-02T00:00:00Z" # Only FAKE_LISTING_002 shares the end_time with the cursor, so it's the only id stored for tie-dedup. assert updates[LastRunKey.COMPLIANCE_LAST_IDS] == ["FAKE_LISTING_002"] def test_fetch_compliance_logs_dedupes_against_previous_ids(mocker): """Happy path: listings whose IDs were already seen at the persisted `last_end_time` are skipped.""" client = _make_client() listings = [ {"id": "FAKE_LISTING_001", "event_type": ComplianceEvent.AUDIT_LOG, "end_time": "2099-01-02T00:00:00Z"}, {"id": "FAKE_LISTING_002", "event_type": ComplianceEvent.AUDIT_LOG, "end_time": "2099-01-02T00:00:00Z"}, ] listing_response = {"data": listings, "last_end_time": "2099-01-02T00:00:00Z", "has_more": False} mocker.patch.object(OpenAiClient, "list_compliance_logs", return_value=listing_response) mocker.patch.object( OpenAiClient, "get_compliance_log_content", side_effect=lambda workspace_id, log_id: [{"id": log_id, "timestamp": "2099-01-02T00:00:00Z"}], ) last_run = { LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-01-02T00:00:00Z", LastRunKey.COMPLIANCE_LAST_IDS: ["FAKE_LISTING_001"], } events, updates = fetch_compliance_logs( client=client, workspace_id="FAKE_WORKSPACE_ID", api_event_types=[ComplianceEvent.AUDIT_LOG], last_run=last_run, max_fetch=100, first_fetch="1 day", ) assert [e["id"] for e in events] == ["FAKE_LISTING_002"] assert updates[LastRunKey.COMPLIANCE_LAST_END_TIME] == "2099-01-02T00:00:00Z" # Same cursor as previous run - merge stored IDs with newly-seen IDs at the same timestamp. assert sorted(updates[LastRunKey.COMPLIANCE_LAST_IDS]) == ["FAKE_LISTING_001", "FAKE_LISTING_002"] def test_fetch_compliance_logs_no_listings_advances_cursor_only(mocker): """Bad path: no listings returned, but the API's `last_end_time` still moves forward -> persist it.""" client = _make_client() mocker.patch.object( OpenAiClient, "list_compliance_logs", return_value={"data": [], "last_end_time": "2099-02-01T00:00:00Z", "has_more": False}, ) last_run = {LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-01-01T00:00:00Z"} events, updates = fetch_compliance_logs( client=client, workspace_id="FAKE_WORKSPACE_ID", api_event_types=[ComplianceEvent.AUDIT_LOG], last_run=last_run, max_fetch=100, first_fetch="1 day", ) assert events == [] assert updates[LastRunKey.COMPLIANCE_LAST_END_TIME] == "2099-02-01T00:00:00Z" assert updates[LastRunKey.COMPLIANCE_LAST_IDS] == [] def test_fetch_compliance_logs_content_failure_isolated(mocker, capfd): """Bad path: a single content-fetch failure must NOT abort processing of other listings.""" client = _make_client() listings = [ {"id": "FAKE_LISTING_001", "event_type": ComplianceEvent.AUDIT_LOG, "end_time": "2099-01-02T00:00:00Z"}, {"id": "FAKE_LISTING_002", "event_type": ComplianceEvent.AUDIT_LOG, "end_time": "2099-01-02T00:00:01Z"}, ] listing_response = {"data": listings, "last_end_time": "2099-01-02T00:00:01Z", "has_more": False} def content_side_effect(workspace_id, log_id): if log_id == "FAKE_LISTING_001": raise DemistoException("simulated content fetch failure") return [{"id": log_id, "timestamp": "2099-01-02T00:00:01Z"}] mocker.patch.object(OpenAiClient, "list_compliance_logs", return_value=listing_response) mocker.patch.object(OpenAiClient, "get_compliance_log_content", side_effect=content_side_effect) # `fetch_compliance_logs` calls `demisto.error(...)` when one or more content fetches fail (writes to stdout). with capfd.disabled(): events, _ = fetch_compliance_logs( client=client, workspace_id="FAKE_WORKSPACE_ID", api_event_types=[ComplianceEvent.AUDIT_LOG], last_run={}, max_fetch=100, first_fetch="1 day", ) # FAKE_LISTING_001 failed and was skipped; FAKE_LISTING_002 succeeded and produced one event. assert [e["id"] for e in events] == ["FAKE_LISTING_002"] # endregion # region Event Collector tests - command-level (fetch-events / openai-get-events) def test_fetch_events_command_runs_streams_in_parallel_and_routes_datasets(mocker): """`fetch_events_command` runs both streams, sends each to its own dataset, persists merged last_run.""" client = _make_client() audit_events = [{"id": "FAKE_AUDIT_EVENT_001"}] compliance_events = [{"id": "FAKE_COMPLIANCE_EVENT_001"}, {"id": "FAKE_COMPLIANCE_EVENT_002"}] mocker.patch.object( module, "fetch_audit_logs", return_value=(audit_events, {module.LastRunKey.AUDIT_AFTER: "FAKE_AUDIT_CURSOR_AAAA"}) ) mocker.patch.object( module, "fetch_compliance_logs", return_value=(compliance_events, {module.LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-02-01T00:00:00Z"}), ) sender = mocker.patch.object(module.OpenAiClient, "send_events") mocker.patch.object(demisto, "getLastRun", return_value={}) set_last_run = mocker.patch.object(demisto, "setLastRun") params = { "event_types_to_fetch": ["OpenAI Audit logs", "Compliance Audit"], "workspace_id": "FAKE_WORKSPACE_ID", "first_fetch": "1 day", "audit_max_fetch": 100, "compliance_max_fetch": 50, } module.fetch_events_command(client=client, params=params) # Both streams pushed - each with its own product. products_used = sorted( call.kwargs.get("product", call.args[1] if len(call.args) > 1 else None) for call in sender.call_args_list ) assert products_used == sorted([module.Config.PRODUCT_AUDIT, module.Config.PRODUCT_COMPLIANCE]) # Last_run is the merged set of per-stream updates. persisted = set_last_run.call_args.args[0] assert persisted[module.LastRunKey.AUDIT_AFTER] == "FAKE_AUDIT_CURSOR_AAAA" assert persisted[module.LastRunKey.COMPLIANCE_LAST_END_TIME] == "2099-02-01T00:00:00Z" def test_fetch_events_command_failure_in_one_stream_pushes_survivor_then_reraises(mocker, capfd): """`fetch_events_command` must: 1. Let the surviving (compliance) stream push its events and persist its cursor. 2. THEN re-raise the failed (audit) stream's exception so the UI shows a real error instead of an opaque "Error pulling at <time>" banner. """ client = _make_client() mocker.patch.object(module, "fetch_audit_logs", side_effect=DemistoException("simulated audit failure")) mocker.patch.object( module, "fetch_compliance_logs", return_value=( [{"id": "FAKE_COMPLIANCE_EVENT_001"}], {module.LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-02-02T00:00:00Z"}, ), ) sender = mocker.patch.object(module.OpenAiClient, "send_events") mocker.patch.object(demisto, "getLastRun", return_value={}) set_last_run = mocker.patch.object(demisto, "setLastRun") params = { "event_types_to_fetch": ["OpenAI Audit logs", "Compliance Audit"], "workspace_id": "FAKE_WORKSPACE_ID", "first_fetch": "1 day", } with capfd.disabled(), pytest.raises(DemistoException) as exc_info: module.fetch_events_command(client=client, params=params) # The re-raised error must name the failing stream so the UI banner is actionable. assert "audit" in str(exc_info.value) assert "simulated audit failure" in str(exc_info.value) # Partial progress is preserved: compliance events were pushed AND its cursor was persisted # BEFORE the re-raise (otherwise the next run would re-fetch the same compliance events). pushed_products = [call.kwargs.get("product", call.args[1] if len(call.args) > 1 else None) for call in sender.call_args_list] assert module.Config.PRODUCT_COMPLIANCE in pushed_products assert module.Config.PRODUCT_AUDIT not in pushed_products assert set_last_run.called persisted = set_last_run.call_args.args[0] assert persisted.get(module.LastRunKey.COMPLIANCE_LAST_END_TIME) == "2099-02-02T00:00:00Z" def test_fetch_events_command_no_event_types_selected_is_a_noop(mocker): """Bad path: if no event types are selected, neither stream runs and last_run is left untouched.""" client = _make_client() audit_mock = mocker.patch.object(module, "fetch_audit_logs") compliance_mock = mocker.patch.object(module, "fetch_compliance_logs") sender = mocker.patch.object(module.OpenAiClient, "send_events") mocker.patch.object(demisto, "getLastRun", return_value={"unrelated": "preserved"}) set_last_run = mocker.patch.object(demisto, "setLastRun") module.fetch_events_command(client=client, params={"event_types_to_fetch": []}) audit_mock.assert_not_called() compliance_mock.assert_not_called() sender.assert_not_called() # Still calls setLastRun once with the original dict (preserves unrelated keys). assert set_last_run.call_args.args[0] == {"unrelated": "preserved"} @pytest.mark.parametrize( "should_push, expected_send_calls", [ pytest.param(False, 0, id="happy-no-push-by-default"), pytest.param(True, 2, id="happy-push-flag-pushes-both-datasets"), ], ) def test_get_events_command(mocker, should_push, expected_send_calls): """`openai-get-events` returns events as `CommandResults`; only pushes when `should_push_events=true`.""" client = _make_client() mocker.patch.object(module, "fetch_audit_logs", return_value=([{"id": "FAKE_AUDIT_EVENT_001"}], {})) mocker.patch.object(module, "fetch_compliance_logs", return_value=([{"id": "FAKE_COMPLIANCE_EVENT_001"}], {})) sender = mocker.patch.object(module.OpenAiClient, "send_events") args = { "event_type": "OpenAI Audit logs,Compliance Audit", "limit": "10", "should_push_events": should_push, } params = {"workspace_id": "FAKE_WORKSPACE_ID"} result = module.get_events_command(client=client, args=args, params=params) # `outputs` always carries every fetched event regardless of the push flag. outputs = cast(list, result.outputs) assert {e["id"] for e in outputs} == {"FAKE_AUDIT_EVENT_001", "FAKE_COMPLIANCE_EVENT_001"} assert sender.call_count == expected_send_calls def test_get_events_command_failure_in_one_stream_does_not_block_the_other(mocker, capfd): """`openai-get-events` must isolate per-stream failures so one stream's exception cannot abort the entire command. Mirrors `fetch_events_command`'s isolation contract: the failing stream is logged via `demisto.error` and skipped, while the surviving stream's events are returned. """ client = _make_client() # Audit stream blows up; compliance stream returns events successfully. mocker.patch.object(module, "fetch_audit_logs", side_effect=DemistoException("simulated audit failure")) mocker.patch.object(module, "fetch_compliance_logs", return_value=([{"id": "FAKE_COMPLIANCE_EVENT_001"}], {})) args = { "event_type": "OpenAI Audit logs,Compliance Audit", "limit": "10", "should_push_events": False, } params = {"workspace_id": "FAKE_WORKSPACE_ID"} with capfd.disabled(): result = module.get_events_command(client=client, args=args, params=params) # The surviving (compliance) stream's events are returned; the failed (audit) stream is dropped. outputs = cast(list, result.outputs) assert {e["id"] for e in outputs} == {"FAKE_COMPLIANCE_EVENT_001"} # endregion # region Event Collector tests - new helpers (refactor coverage) @pytest.mark.parametrize( "raw, expected", [ pytest.param({"password": "FAKE_KEY"}, "FAKE_KEY", id="happy-dict-shape-type-9"), pytest.param({"identifier": "user", "password": "FAKE_KEY"}, "FAKE_KEY", id="happy-dict-shape-with-identifier"), pytest.param("FAKE_KEY", "FAKE_KEY", id="happy-plain-string-type-14"), pytest.param(None, "", id="happy-none-yields-empty"), pytest.param({}, "", id="happy-empty-dict-yields-empty"), pytest.param({"password": ""}, "", id="happy-dict-with-empty-password"), pytest.param({"password": None}, "", id="happy-dict-with-none-password"), ], ) def test_extract_credential(raw, expected): """`_extract_credential` accepts both dict-shape (legacy type:9) and string-shape (modern type:14).""" assert _extract_credential(raw) == expected @pytest.mark.parametrize( "raw, expected", [ pytest.param(None, [], id="happy-none-yields-empty-list"), pytest.param([], [], id="happy-empty-list-yields-empty-list"), pytest.param(["OpenAI Audit logs"], ["OpenAI Audit logs"], id="happy-single-audit-label"), pytest.param( ["OpenAI Audit logs", "Compliance Audit"], ["OpenAI Audit logs", "Compliance Audit"], id="happy-mixed-audit-and-compliance", ), pytest.param( "OpenAI Audit logs,Compliance Audit", ["OpenAI Audit logs", "Compliance Audit"], id="happy-csv-string", ), ], ) def test_parse_event_types_to_fetch_happy_paths(raw, expected): """`parse_event_types_to_fetch` normalizes various inputs into the canonical list.""" assert parse_event_types_to_fetch(raw) == expected def test_parse_event_types_to_fetch_rejects_unknown_labels(): """Bad path: unknown labels surface as an informative `DemistoException`.""" with pytest.raises(DemistoException) as exc_info: parse_event_types_to_fetch(["NotAnEventType"]) assert "Invalid event type" in str(exc_info.value) @pytest.mark.parametrize( "audit_selected, api_event_types, expected_streams", [ pytest.param(False, [], [], id="none-selected-no-streams"), pytest.param(True, [], ["audit"], id="audit-only"), pytest.param(False, ["AUDIT_LOG"], ["compliance"], id="compliance-only"), pytest.param(True, ["AUDIT_LOG"], ["audit", "compliance"], id="both-streams-ordered-audit-first"), ], ) def test_collector_params_streams_to_run(audit_selected, api_event_types, expected_streams): """`CollectorParams.streams_to_run` selects audit + compliance based on flags.""" params = CollectorParams( event_types_to_fetch=[], audit_selected=audit_selected, api_event_types=api_event_types, audit_max_fetch=10, compliance_max_fetch=10, workspace_id="", first_fetch="1 day", ) assert params.streams_to_run() == expected_streams assert params.compliance_selected is bool(api_event_types) def test_parse_collector_params_no_args_uses_integration_defaults(): """Happy path: when called from fetch-events (no args), values come from integration params.""" params = { "event_types_to_fetch": ["OpenAI Audit logs", "Compliance Audit"], "audit_max_fetch": "200", "compliance_max_fetch": "150", "workspace_id": "FAKE_WORKSPACE_ID", "first_fetch": "3 days", } collector = parse_collector_params(params) assert collector.event_types_to_fetch == ["OpenAI Audit logs", "Compliance Audit"] assert collector.audit_selected is True assert collector.api_event_types == ["AUDIT_LOG"] assert collector.audit_max_fetch == 200 assert collector.compliance_max_fetch == 150 assert collector.workspace_id == "FAKE_WORKSPACE_ID" assert collector.first_fetch == Config.DEFAULT_FIRST_FETCH minimal = parse_collector_params({"event_types_to_fetch": []}) assert minimal.audit_max_fetch == Config.DEFAULT_AUDIT_MAX_FETCH assert minimal.compliance_max_fetch == Config.DEFAULT_COMPLIANCE_MAX_FETCH assert minimal.first_fetch == Config.DEFAULT_FIRST_FETCH def test_parse_collector_params_with_args_applies_overrides(): """Happy path: args (manual command) override `event_type` / `limit` / `start_time`.""" params = { "event_types_to_fetch": ["OpenAI Audit logs"], "audit_max_fetch": "999", "compliance_max_fetch": "999", "workspace_id": "FAKE_WORKSPACE_ID", "first_fetch": "1 day", } args = { "event_type": "Compliance Audit", "limit": "5", "start_time": "2 hours ago", } collector = parse_collector_params(params, args=args) assert collector.event_types_to_fetch == ["Compliance Audit"] assert collector.audit_selected is False assert collector.api_event_types == ["AUDIT_LOG"] assert collector.audit_max_fetch == 5 assert collector.compliance_max_fetch == 5 assert collector.first_fetch == "2 hours ago" def test_parse_collector_params_with_empty_args_falls_back_to_params(): """Happy path: an empty `args` dict (falsy) skips arg-overrides and uses integration-param values.""" params = {"event_types_to_fetch": ["OpenAI Audit logs"], "first_fetch": "5 days"} collector = parse_collector_params(params, args={}) assert collector.event_types_to_fetch == ["OpenAI Audit logs"] # Empty args is falsy -> falls into the fetch-events branch -> uses integration defaults. assert collector.audit_max_fetch == Config.DEFAULT_AUDIT_MAX_FETCH assert collector.compliance_max_fetch == Config.DEFAULT_COMPLIANCE_MAX_FETCH assert collector.first_fetch == Config.DEFAULT_FIRST_FETCH def test_fetch_stream_dispatches_to_audit(mocker): """`fetch_stream(stream='audit')` calls `fetch_audit_logs` and routes to PRODUCT_AUDIT.""" audit_events = [{"id": "FAKE_AUDIT_EVENT_001"}] audit_updates = {module.LastRunKey.AUDIT_AFTER: "FAKE_CURSOR"} mocker.patch.object(module, "fetch_audit_logs", return_value=(audit_events, audit_updates)) client = _make_client() collector = module.CollectorParams( event_types_to_fetch=["OpenAI Audit logs"], audit_selected=True, api_event_types=[], audit_max_fetch=10, compliance_max_fetch=10, workspace_id="", first_fetch="1 day", ) result = module.fetch_stream(client=client, stream=module.Stream.AUDIT, last_run={}, collector_params=collector) assert result.stream == module.Stream.AUDIT assert result.events == audit_events assert result.last_run_updates == audit_updates assert result.product == module.Config.PRODUCT_AUDIT def test_fetch_stream_dispatches_to_compliance(mocker): """`fetch_stream(stream='compliance')` calls `fetch_compliance_logs` and routes to PRODUCT_COMPLIANCE.""" compliance_events = [{"id": "FAKE_COMPLIANCE_EVENT_001"}] compliance_updates = {module.LastRunKey.COMPLIANCE_LAST_END_TIME: "2099-01-01T00:00:00Z"} mocker.patch.object(module, "fetch_compliance_logs", return_value=(compliance_events, compliance_updates)) client = _make_client() collector = module.CollectorParams( event_types_to_fetch=["Compliance Audit"], audit_selected=False, api_event_types=["AUDIT_LOG"], audit_max_fetch=10, compliance_max_fetch=10, workspace_id="FAKE_WORKSPACE_ID", first_fetch="1 day", ) result = module.fetch_stream(client=client, stream=module.Stream.COMPLIANCE, last_run={}, collector_params=collector) assert result.stream == module.Stream.COMPLIANCE assert result.events == compliance_events assert result.last_run_updates == compliance_updates assert result.product == module.Config.PRODUCT_COMPLIANCE def test_fetch_stream_compliance_skipped_without_workspace_id(mocker): """Bad path: compliance stream returns an empty FetchResult when workspace_id is missing.""" compliance_mock = mocker.patch.object(module, "fetch_compliance_logs") client = _make_client() collector = module.CollectorParams( event_types_to_fetch=["Compliance Audit"], audit_selected=False, api_event_types=["AUDIT_LOG"], audit_max_fetch=10, compliance_max_fetch=10, workspace_id="", first_fetch="1 day", ) result = module.fetch_stream(client=client, stream=module.Stream.COMPLIANCE, last_run={}, collector_params=collector) assert result.stream == module.Stream.COMPLIANCE assert result.events == [] assert result.last_run_updates == {} compliance_mock.assert_not_called() def test_fetch_stream_unknown_stream_raises(): """Bad path: an unknown stream identifier raises `DemistoException`.""" client = _make_client() collector = module.CollectorParams( event_types_to_fetch=[], audit_selected=False, api_event_types=[], audit_max_fetch=10, compliance_max_fetch=10, workspace_id="", first_fetch="1 day", ) with pytest.raises(DemistoException) as exc_info: module.fetch_stream(client=client, stream="not_a_stream", last_run={}, collector_params=collector) assert "Unknown stream identifier" in str(exc_info.value) def test_push_result_sends_events_when_present(mocker): """Happy path: `_push_result` calls `client.send_events(...)` with the events and product.""" client = _make_client() sender = mocker.patch.object(module.OpenAiClient, "send_events") result = module.FetchResult( stream=module.Stream.AUDIT, events=[{"id": "FAKE_AUDIT_EVENT_001"}], product=module.Config.PRODUCT_AUDIT, ) module._push_result(client, result, log_prefix="[Test]") sender.assert_called_once_with([{"id": "FAKE_AUDIT_EVENT_001"}], product=module.Config.PRODUCT_AUDIT) def test_push_result_skips_when_no_events(mocker): """Happy path: empty events list short-circuits without calling the sender.""" client = _make_client() sender = mocker.patch.object(module.OpenAiClient, "send_events") result = module.FetchResult(stream=module.Stream.AUDIT, events=[], product=module.Config.PRODUCT_AUDIT) module._push_result(client, result, log_prefix="[Test]") sender.assert_not_called() def test_push_result_isolates_send_failure(mocker, capfd): """Bad path: a `send_events` exception is caught and logged; does NOT propagate.""" client = _make_client() mocker.patch.object(module.OpenAiClient, "send_events", side_effect=RuntimeError("simulated push failure")) result = module.FetchResult( stream=module.Stream.AUDIT, events=[{"id": "FAKE_AUDIT_EVENT_001"}], product=module.Config.PRODUCT_AUDIT, ) # `_push_result` swallows the failure and calls `demisto.error(...)` which writes to stdout in the test runtime. with capfd.disabled(): module._push_result(client, result, log_prefix="[Test]") # endregion # region Event Collector tests - test_module def test_test_module_chat_only_returns_ok(mocker): """Happy path: chat-completions probe passes and no collector streams selected returns ok.""" client = _make_client(admin_api_key="", compliance_api_key="") mocker.patch.object(module.OpenAiClient, "get_chat_completions", return_value={"choices": []}) mocker.patch.object(module.OpenAiClient, "create_response", return_value={"id": "resp_test", "output": []}) fetch_mock = mocker.patch.object(module, "fetch_stream") result = module.test_module(client=client, params={}) assert result == "ok" fetch_mock.assert_not_called() def test_test_module_chat_completions_auth_error_returns_friendly_message(mocker, capfd): """Bad path: a chat-completions auth error returns the friendly auth-error string instead of raising.""" client = _make_client() mocker.patch.object(module.OpenAiClient, "get_chat_completions", side_effect=DemistoException("403 Forbidden access")) # `test_module` calls `demisto.error(...)` on the auth-error path which writes to stdout in the test runtime. with capfd.disabled(): result = module.test_module(client=client, params={}) assert "Authorization Error" in result def test_test_module_chat_completions_non_auth_error_propagates(mocker, capfd): """Bad path: a non-auth error from chat-completions propagates so the test still fails clearly.""" client = _make_client() mocker.patch.object(module.OpenAiClient, "get_chat_completions", side_effect=DemistoException("simulated 500 error")) # `test_module` calls `demisto.error(...)` on the failure path which writes to stdout in the test runtime; # disable the stdout-capture fixture for this test so we can assert on the raise. with capfd.disabled(), pytest.raises(DemistoException) as exc_info: module.test_module(client=client, params={}) assert "simulated 500 error" in str(exc_info.value) def test_test_module_probes_collector_streams_with_max_one(mocker): """Happy path: with audit selected + admin key, fetch_stream is called once with max_fetch=1.""" client = _make_client(admin_api_key="ADMIN_KEY", compliance_api_key="") mocker.patch.object(module.OpenAiClient, "get_chat_completions", return_value={"choices": []}) mocker.patch.object(module.OpenAiClient, "create_response", return_value={"id": "resp_test", "output": []}) fetch_mock = mocker.patch.object(module, "fetch_stream") params = {"event_types_to_fetch": ["OpenAI Audit logs"]} result = module.test_module(client=client, params=params) assert result == "ok" fetch_mock.assert_called_once() call_kwargs = fetch_mock.call_args.kwargs assert call_kwargs["stream"] == module.Stream.AUDIT assert call_kwargs["last_run"] == {} assert call_kwargs["collector_params"].audit_max_fetch == module.Config.TEST_MODULE_MAX_EVENTS assert call_kwargs["collector_params"].compliance_max_fetch == module.Config.TEST_MODULE_MAX_EVENTS def test_test_module_skips_compliance_when_workspace_id_missing(mocker): """Bad path: compliance selected + key set, but no workspace_id - compliance probe is skipped.""" client = _make_client(admin_api_key="", compliance_api_key="COMPLIANCE_KEY") mocker.patch.object(module.OpenAiClient, "get_chat_completions", return_value={"choices": []}) mocker.patch.object(module.OpenAiClient, "create_response", return_value={"id": "resp_test", "output": []}) fetch_mock = mocker.patch.object(module, "fetch_stream") params = {"event_types_to_fetch": ["Compliance Audit"], "workspace_id": ""} result = module.test_module(client=client, params=params) assert result == "ok" fetch_mock.assert_not_called() def test_test_module_propagates_collector_probe_failure(mocker, capfd): """Bad path: a fetch_stream failure during the probe raises a stream-tagged DemistoException.""" client = _make_client(admin_api_key="ADMIN_KEY", compliance_api_key="") mocker.patch.object(module.OpenAiClient, "get_chat_completions", return_value={"choices": []}) mocker.patch.object(module.OpenAiClient, "create_response", return_value={"id": "resp_test", "output": []}) mocker.patch.object(module, "fetch_stream", side_effect=DemistoException("simulated audit probe failure")) params = {"event_types_to_fetch": ["OpenAI Audit logs"]} # `test_module` calls `demisto.error(...)` on the failure path which writes to stdout in the test runtime; # disable the stdout-capture fixture for this test so we can assert on the raise. with capfd.disabled(), pytest.raises(DemistoException) as exc_info: module.test_module(client=client, params=params) msg = str(exc_info.value) assert "audit" in msg assert "simulated audit probe failure" in msg # endregion # region Optional-chat-key coverage def test_get_chat_completions_requires_api_key(): """Bad path: chat-completions runtime guard raises a clear error when the API Key is missing.""" client = _make_client(api_key="") with pytest.raises(DemistoException) as exc_info: client.get_chat_completions(chat_context=[{"role": "user", "content": "x"}], completion_params={}) msg = str(exc_info.value) assert "API Key is required" in msg assert "gpt-send-message" in msg def test_test_module_skips_chat_probe_when_api_key_missing(mocker): """Happy path: with no chat key but a configured collector, the chat probe is skipped and only the collector runs.""" client = _make_client(api_key="", admin_api_key="ADMIN_KEY", compliance_api_key="") chat_mock = mocker.patch.object(module.OpenAiClient, "get_chat_completions") fetch_mock = mocker.patch.object(module, "fetch_stream") params = {"event_types_to_fetch": ["OpenAI Audit logs"]} result = module.test_module(client=client, params=params) assert result == "ok" chat_mock.assert_not_called() fetch_mock.assert_called_once() def test_test_module_no_capability_configured_raises(): """Bad path: no chat key AND no usable collector stream - the test is meaningless and must raise.""" client = _make_client(api_key="", admin_api_key="", compliance_api_key="") with pytest.raises(DemistoException) as exc_info: module.test_module(client=client, params={}) msg = str(exc_info.value) assert "No capability is configured" in msg assert "API Key" in msg # endregion # region Compliance API limit-cap regression def test_list_compliance_logs_clamps_limit_to_compliance_page_size(mocker): """Bad path regression: the ChatGPT Platform Compliance API rejects limit > 100 with HTTP 422. `list_compliance_logs` must clamp any caller-supplied `limit` (e.g. the integration default of `compliance_max_fetch=900`) to `Config.COMPLIANCE_PAGE_SIZE` (100) before sending the request. """ client = _make_client() captured: dict = {} def fake_http(*args, **kwargs): captured.update(kwargs) return {"data": [], "last_end_time": None} mocker.patch.object(OpenAiClient, "_http_request", side_effect=fake_http) client.list_compliance_logs( workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z", limit=900, # caller asks for 900; API only allows 100 ) sent_limits = [v for (k, v) in captured.get("params", []) if k == "limit"] assert sent_limits == [Config.COMPLIANCE_PAGE_SIZE] # endregion # region JSON-decode-resilience regression (Extra data / Expecting value) @pytest.mark.parametrize( "raw_body, expected_kind", [ pytest.param('{"a":1}', dict, id="happy-single-json-object"), pytest.param('[{"a":1},{"b":2}]', list, id="happy-single-json-array"), pytest.param('{"a":1}\n{"b":2}\n{"c":3}', list, id="bad-jsonl-falls-back-to-records-list"), pytest.param('{"a":1}{"b":2}', list, id="bad-concatenated-objects-fall-back-to-records-list"), pytest.param("", list, id="bad-empty-body-yields-empty-list"), pytest.param(" \n ", list, id="bad-whitespace-only-body-yields-empty-list"), pytest.param(None, list, id="bad-none-body-yields-empty-list"), pytest.param({"data": []}, dict, id="passthrough-already-parsed-dict"), pytest.param([{"x": 1}], list, id="passthrough-already-parsed-list"), ], ) def test_parse_json_or_concatenated_resilience(raw_body, expected_kind): """`_parse_json_or_concatenated` must tolerate JSON, JSONL, concatenated JSON, empty bodies, and pre-parsed objects.""" result = _parse_json_or_concatenated(raw_body, log_prefix="[Test]") assert isinstance(result, expected_kind) def test_get_audit_logs_recovers_from_jsonl_response(mocker): """Regression: production observed `Extra data: line 1 column N` when the audit endpoint returned JSONL. With the defensive parser, JSONL responses now degrade to a list of records and the audit fetch wraps them in the `{data, has_more, last_id}` envelope so the rest of the pipeline keeps working. """ client = _make_client() # Concatenated/JSONL body - the source of the production "Extra data" failure. jsonl_body = '{"id": "FAKE_LOG_001", "effective_at": 100}\n{"id": "FAKE_LOG_002", "effective_at": 200}\n' mocker.patch.object(OpenAiClient, "_http_request", return_value=jsonl_body) response = client.get_audit_logs() assert isinstance(response, dict) assert [event["id"] for event in response.get("data", [])] == ["FAKE_LOG_001", "FAKE_LOG_002"] assert response.get("has_more") is False assert response.get("last_id") is None def test_list_compliance_logs_recovers_from_jsonl_response(mocker): """Regression: production observed `Expecting value: line 2 column 1` from the compliance listing endpoint. With the defensive parser, the body is parsed as concatenated JSON / JSONL and normalized to `{data, last_end_time, has_more}`. """ client = _make_client() # JSONL body - first line has a valid object, then a newline + another object. jsonl_body = ( '{"id": "FAKE_LISTING_001", "event_type": "AUDIT_LOG", "end_time": "2099-01-01T00:00:00Z"}\n' '{"id": "FAKE_LISTING_002", "event_type": "AUDIT_LOG", "end_time": "2099-01-01T00:00:01Z"}' ) mocker.patch.object(OpenAiClient, "_http_request", return_value=jsonl_body) result = client.list_compliance_logs( workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z" ) assert [entry["id"] for entry in result["data"]] == ["FAKE_LISTING_001", "FAKE_LISTING_002"] # endregion # region JSONL-shaped response regression (synthetic, models the production payload structure) def test_parse_concatenated_json_handles_audit_log_jsonl_payload_with_trailing_newlines(): """Regression: the AUDIT_LOG JSONL payload shape returned by /compliance/workspaces/.../logs/{log_id}. Two records on their own lines, separated by \\n, followed by trailing whitespace. All identifiers, timestamps, IPs, and key suffixes below are FAKE placeholders chosen to mimic the production wire shape without reproducing any real customer data or PII. """ body = ( '{"event_id":"FAKE_EVENT_ID_001","type":"AUDIT_LOG",' '"principal":{"id":"FAKE_WORKSPACE_ID","type":"CHATGPT_WORKSPACE"},' '"actor":{"type":"API_KEY","redacted_id":"FAKE_REDACTED_KEY"},' '"timestamp":"2099-01-01T00:00:00.000000Z","action_result":"ERROR","action_privilege":"ADMIN",' '"action_data":{"limit":"10","event_type":"CONVERSATION_MESSAGE"},' '"action":"LIST_WORKSPACE_LOG_FILES"}\n' '{"event_id":"FAKE_EVENT_ID_002","type":"AUDIT_LOG",' '"principal":{"id":"FAKE_WORKSPACE_ID","type":"CHATGPT_WORKSPACE"},' '"actor":{"type":"API_KEY","redacted_id":"FAKE_REDACTED_KEY"},' '"timestamp":"2099-01-01T00:00:01.000000Z","action_result":"SUCCESS","action_privilege":"ADMIN",' '"action_data":{"after":"2099-01-01T00:00:00","event_type":"CONVERSATION_MESSAGE"},' '"action":"LIST_WORKSPACE_LOG_FILES"}\n\n' ) records = parse_concatenated_json(body) assert len(records) == 2 assert records[0]["event_id"] == "FAKE_EVENT_ID_001" assert records[0]["action_result"] == "ERROR" assert records[1]["event_id"] == "FAKE_EVENT_ID_002" assert records[1]["action_result"] == "SUCCESS" # Nested objects preserved through the parse. assert records[0]["principal"]["id"] == "FAKE_WORKSPACE_ID" assert records[1]["actor"]["type"] == "API_KEY" # endregion # region List Models tests def test_list_models_command(mocker): """list_models_command returns a CommandResults with Id/Created/OwnedBy outputs. The Created field should be an ISO 8601 string (converted from Unix timestamp). """ mock_response = { "object": "list", "data": [ {"id": "gpt-4", "created": 1687882410, "owned_by": "openai", "object": "model"}, {"id": "gpt-3.5-turbo", "created": 1677610602, "owned_by": "openai", "object": "model"}, ], } client = _make_client() mocker.patch.object(client, "list_models", return_value=mock_response) result = list_models_command(client=client) assert isinstance(result, CommandResults) assert result.outputs_prefix == "OpenAiChatGPTV3.Model" assert result.outputs_key_field == "Id" assert len(result.outputs) == 2 assert result.outputs[0]["Id"] == "gpt-4" assert result.outputs[0]["OwnedBy"] == "openai" # Created should be an ISO 8601 string, not a raw Unix timestamp assert isinstance(result.outputs[0]["Created"], str) assert "2023" in result.outputs[0]["Created"] assert result.outputs[1]["Id"] == "gpt-3.5-turbo" assert result.outputs[1]["OwnedBy"] == "openai" assert isinstance(result.outputs[1]["Created"], str) assert "OpenAI Models" in result.readable_output def test_list_models_command_empty_response(mocker): """list_models_command handles an empty data list gracefully.""" mock_response = {"object": "list", "data": []} client = _make_client() mocker.patch.object(client, "list_models", return_value=mock_response) result = list_models_command(client=client) assert isinstance(result, CommandResults) assert result.outputs == [] assert "OpenAI Models" in result.readable_output def test_list_models_requires_api_key(): """list_models raises DemistoException when no API key is configured.""" client = _make_client(api_key="") with pytest.raises(DemistoException, match="API Key is required"): client.list_models() def test_create_response_requires_api_key(): """create_response raises DemistoException when no API key is configured.""" client = _make_client(api_key="") with pytest.raises(DemistoException, match="API Key is required"): client.create_response({"model": "gpt-4", "input": "Hello"}) def test_get_response_requires_api_key(): """get_response raises DemistoException when no API key is configured.""" client = _make_client(api_key="") with pytest.raises(DemistoException, match="API Key is required"): client.get_response("resp_FAKE_001") # endregion # region Moderation tests MOCK_MODERATION_RESPONSE: dict = { "id": "modr-abc123", "model": "omni-moderation-latest", "results": [ { "flagged": True, "categories": { "violence": True, "harassment/threatening": False, "self-harm": False, }, "category_scores": { "violence": 0.9430, "harassment/threatening": 0.2842, "self-harm": 0.0001, }, } ], } MOCK_MODERATION_MULTI_RESPONSE: dict = { "id": "modr-multi123", "model": "omni-moderation-latest", "results": [ { "flagged": False, "categories": { "violence": False, "harassment/threatening": False, "self-harm": False, }, "category_scores": { "violence": 0.0010, "harassment/threatening": 0.0005, "self-harm": 0.0001, }, }, { "flagged": True, "categories": { "violence": True, "harassment/threatening": False, "self-harm": False, }, "category_scores": { "violence": 0.9430, "harassment/threatening": 0.2842, "self-harm": 0.0001, }, }, ], } def test_validate_create_moderation_args_no_input(): """validate_create_moderation_args raises when no input is provided.""" with pytest.raises(DemistoException, match="Exactly one of"): validate_create_moderation_args({}) def test_validate_create_moderation_args_multiple_inputs(): """validate_create_moderation_args raises when more than one input is provided.""" with pytest.raises(DemistoException, match="Only one of"): validate_create_moderation_args({"text": "hello", "image_url": "https://example.com/img.png"}) def test_validate_create_moderation_args_text_only(): """validate_create_moderation_args passes with text only.""" validate_create_moderation_args({"text": "hello"}) def test_validate_create_moderation_args_entry_id_only(): """validate_create_moderation_args passes with entry_id only.""" validate_create_moderation_args({"entry_id": "3@123"}) def test_validate_create_moderation_args_image_url_only(): """validate_create_moderation_args passes with image_url only.""" validate_create_moderation_args({"image_url": "https://example.com/img.png"}) def test_create_moderation_command_text(mocker): """create_moderation_command with text input returns correct outputs.""" client = _make_client() mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_RESPONSE) result = create_moderation_command(client=client, args={"text": "I will hurt someone"}) assert isinstance(result, CommandResults) assert result.outputs_prefix == "OpenAiChatGPTV3.Moderation" # Single text → outputs is a dict (backward compatible) assert result.outputs["Flagged"] is True assert result.outputs["Input"] == {"input_type": "text", "input_value": "I will hurt someone"} assert result.outputs["Categories"]["violence"] is True assert result.outputs["Categories"]["harassment/threatening"] is False assert result.outputs["CategoryScores"]["violence"] == pytest.approx(0.9430) assert "✅" in result.readable_output assert "❌" in result.readable_output # Verify the API was called with text input call_body = client.create_moderation.call_args[0][0] assert call_body["model"] == "omni-moderation-latest" assert call_body["input"] == ["I will hurt someone"] def test_create_moderation_command_text_array(mocker): """create_moderation_command with comma-separated text returns per-text outputs.""" client = _make_client() mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_MULTI_RESPONSE) result = create_moderation_command( client=client, args={"text": "hello,goodbye"}, ) call_body = client.create_moderation.call_args[0][0] assert call_body["input"] == ["hello", "goodbye"] assert isinstance(result, CommandResults) # Multiple texts → outputs is a list assert isinstance(result.outputs, list) assert len(result.outputs) == 2 # First text result assert result.outputs[0]["Input"] == {"input_type": "text", "input_value": "hello"} assert result.outputs[0]["Flagged"] is False assert result.outputs[0]["Categories"]["violence"] is False assert result.outputs[0]["CategoryScores"]["violence"] == pytest.approx(0.0010) # Second text result assert result.outputs[1]["Input"] == {"input_type": "text", "input_value": "goodbye"} assert result.outputs[1]["Flagged"] is True assert result.outputs[1]["Categories"]["violence"] is True assert result.outputs[1]["CategoryScores"]["violence"] == pytest.approx(0.9430) # War room should contain separate tables for each text assert '"hello"' in result.readable_output assert '"goodbye"' in result.readable_output def test_create_moderation_command_image_url(mocker): """create_moderation_command with image_url sends correct body.""" client = _make_client() mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_RESPONSE) result = create_moderation_command( client=client, args={"image_url": "https://example.com/image.png"}, ) call_body = client.create_moderation.call_args[0][0] assert call_body["input"] == [{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}] assert isinstance(result, CommandResults) def test_create_moderation_command_entry_id(mocker): """create_moderation_command with entry_id base64-encodes the image.""" client = _make_client() mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_RESPONSE) mocker.patch( "OpenAiChatGPTV3._entry_id_to_data_url", return_value="data:image/png;base64,AAAA", ) result = create_moderation_command( client=client, args={"entry_id": "3@123"}, ) call_body = client.create_moderation.call_args[0][0] assert call_body["input"] == [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}] assert isinstance(result, CommandResults) def test_create_moderation_command_empty_results(mocker): """create_moderation_command handles empty results gracefully.""" client = _make_client() mocker.patch.object(client, "create_moderation", return_value={"results": []}) result = create_moderation_command(client=client, args={"text": "hello"}) assert isinstance(result.outputs, list) assert result.outputs[0]["Flagged"] is False assert result.outputs[0]["Categories"] == {} assert result.outputs[0]["Input"] == {"input_type": "text", "input_value": "hello"} assert "No moderation results" in result.readable_output def test_create_moderation_command_custom_model(mocker): """create_moderation_command passes the model argument through.""" client = _make_client() mocker.patch.object(client, "create_moderation", return_value=MOCK_MODERATION_RESPONSE) create_moderation_command( client=client, args={"text": "test", "model": "omni-moderation-2024-09-26"}, ) call_body = client.create_moderation.call_args[0][0] assert call_body["model"] == "omni-moderation-2024-09-26" def test_create_moderation_requires_api_key(): """create_moderation raises DemistoException when no API key is configured.""" client = _make_client(api_key="") with pytest.raises(DemistoException, match="API Key is required"): client.create_moderation({"model": "omni-moderation-latest", "input": ["test"]}) def test_entry_id_to_data_url(mocker, tmp_path): """_entry_id_to_data_url encodes an image file to a data URL.""" # Create a fake PNG file (just needs the right extension for MIME detection) fake_image = tmp_path / "test.png" fake_image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 10) mocker.patch( "OpenAiChatGPTV3.demisto.getFilePath", return_value={"path": str(fake_image), "name": "test.png"}, ) result = _entry_id_to_data_url("3@123") assert result.startswith("data:image/png;base64,") # Verify the base64 payload is valid import base64 payload = result.split(",", 1)[1] decoded = base64.b64decode(payload) assert decoded[:4] == b"\x89PNG" def test_entry_id_to_data_url_non_image(mocker, tmp_path): """_entry_id_to_data_url raises for non-image files.""" fake_file = tmp_path / "doc.pdf" fake_file.write_bytes(b"%PDF-1.4") mocker.patch( "OpenAiChatGPTV3.demisto.getFilePath", return_value={"path": str(fake_file), "name": "doc.pdf"}, ) with pytest.raises(DemistoException, match="Unsupported or unknown image type"): _entry_id_to_data_url("3@444") def test_entry_id_to_data_url_file_not_found(mocker): """_entry_id_to_data_url raises when file is not found.""" mocker.patch( "OpenAiChatGPTV3.demisto.getFilePath", return_value=None, ) with pytest.raises(DemistoException, match="Could not find file"): _entry_id_to_data_url("3@999") # endregion # endregion # region Tests - create_response_command (gpt-create-response) # ============================================================= MOCK_COMPLETED_RESPONSE = { "id": "resp_FAKE_001", "object": "response", "status": "completed", "model": "gpt-4", "output": [ { "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Hello from the Responses API!"}], } ], "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15, "output_tokens_details": {"reasoning_tokens": 0}, }, } def test_create_response_command_sync_happy_path(mocker): """Happy path: synchronous (no background) completion returns CommandResults directly. The @polling_function decorator returns ``func(...).response`` when the polling arg (``background``) is falsy, so the caller receives a ``CommandResults`` object. """ mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE) mocker.patch.object(demisto, "context", return_value={}) client = _make_client() args = {"message": "Hello!", "reset_conversation_history": "yes"} result = create_response_command(args=args, client=client, params={}) # The decorator unwraps PollResult.response → CommandResults assert isinstance(result, CommandResults) assert result.outputs_prefix == "OpenAiChatGPTV3.Response" assert result.raw_response == MOCK_COMPLETED_RESPONSE assert isinstance(result.outputs, list) assert len(result.outputs) == 1 assert result.outputs[0]["response_id"] == "resp_FAKE_001" assert result.outputs[0]["assistant"] == "Hello from the Responses API!" assert result.outputs[0]["user"] == "Hello!" def test_create_response_command_missing_message(): """Bad path: missing 'message' argument raises DemistoException.""" client = _make_client() with pytest.raises(DemistoException, match="message"): create_response_command(args={}, client=client, params={}) def test_create_response_command_no_model(): """Bad path: no model in args or client raises DemistoException.""" client = _make_client(model="") with pytest.raises(DemistoException, match="No model specified"): create_response_command(args={"message": "Hi"}, client=client, params={}) def test_create_response_command_background_starts_polling(mocker): """Happy path: background=true with queued status starts polling. The @polling_function decorator calls ScheduledCommand.raise_error_if_not_supported() when the polling arg is truthy, so we must mock it. """ queued_response = { "id": "resp_FAKE_BG", "status": "queued", "model": "gpt-4", "output": [], "usage": {}, } mocker.patch.object(OpenAiClient, "_http_request", return_value=queued_response) mocker.patch.object(demisto, "context", return_value={}) mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported") client = _make_client() args = {"message": "Background task", "reset_conversation_history": "yes", "background": "true"} result = create_response_command(args=args, client=client, params={}) # The decorator wraps the partial_result with a ScheduledCommand when continue_to_poll=True assert isinstance(result, CommandResults) assert result.scheduled_command is not None def test_create_response_command_polling_still_pending(mocker): """Polling re-entry: in_progress status continues polling. On re-entry the decorator sees ``background=true`` (still in args) and enters the polling path. ``_polling_response_id`` triggers the poll branch inside the command. """ pending_response = {"id": "resp_FAKE_POLL", "status": "in_progress"} mocker.patch.object(OpenAiClient, "_http_request", return_value=pending_response) mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported") client = _make_client() args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_POLL"} result = create_response_command(args=args, client=client, params={}) # Still polling → decorator returns partial_result with ScheduledCommand assert isinstance(result, CommandResults) assert result.scheduled_command is not None def test_create_response_command_polling_completed(mocker): """Polling re-entry: completed status returns final CommandResults.""" mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE) mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported") client = _make_client() args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_001"} result = create_response_command(args=args, client=client, params={}) # Completed → decorator returns PollResult.response (CommandResults) assert isinstance(result, CommandResults) assert result.outputs_prefix == "OpenAiChatGPTV3.Response" assert result.outputs[0]["response_id"] == "resp_FAKE_001" def test_create_response_command_polling_failed(mocker): """Polling re-entry: failed status raises DemistoException.""" failed_response = { "id": "resp_FAKE_FAIL", "status": "failed", "error": {"message": "Rate limit exceeded", "code": "rate_limit"}, } mocker.patch.object(OpenAiClient, "_http_request", return_value=failed_response) mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported") client = _make_client() args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_FAIL"} with pytest.raises(DemistoException, match="Rate limit exceeded"): create_response_command(args=args, client=client, params={}) def test_create_response_command_polling_incomplete(mocker): """Polling re-entry: incomplete status raises DemistoException.""" incomplete_response = { "id": "resp_FAKE_INC", "status": "incomplete", "incomplete_details": {"reason": "max_output_tokens"}, } mocker.patch.object(OpenAiClient, "_http_request", return_value=incomplete_response) mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported") client = _make_client() args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_INC"} with pytest.raises(DemistoException, match="max_output_tokens"): create_response_command(args=args, client=client, params={}) def test_create_response_command_polling_cancelled(mocker): """Polling re-entry: cancelled status raises DemistoException.""" cancelled_response = {"id": "resp_FAKE_CAN", "status": "cancelled"} mocker.patch.object(OpenAiClient, "_http_request", return_value=cancelled_response) mocker.patch.object(ScheduledCommand, "raise_error_if_not_supported") client = _make_client() args = {"message": "Hi", "background": "true", "_polling_response_id": "resp_FAKE_CAN"} with pytest.raises(DemistoException, match="cancelled"): create_response_command(args=args, client=client, params={}) def test_create_response_command_conversation_continuity(mocker): """Happy path: previous_response_id is sent when conversation context exists.""" mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE) mocker.patch.object( demisto, "context", return_value={"OpenAiChatGPTV3": {"Response": [{"response_id": "resp_PREV_001"}]}}, ) client = _make_client() args = {"message": "Follow-up question", "reset_conversation_history": "no"} create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE) result = create_response_command(args=args, client=client, params={}) assert isinstance(result, CommandResults) call_body = create_spy.call_args[0][0] assert call_body["previous_response_id"] == "resp_PREV_001" def test_create_response_command_reset_conversation(mocker): """Happy path: reset_conversation_history=yes does NOT send previous_response_id.""" mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE) mocker.patch.object( demisto, "context", return_value={"OpenAiChatGPTV3": {"Response": [{"response_id": "resp_PREV_001"}]}}, ) client = _make_client() args = {"message": "Fresh start", "reset_conversation_history": "yes"} create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE) result = create_response_command(args=args, client=client, params={}) assert isinstance(result, CommandResults) call_body = create_spy.call_args[0][0] assert "previous_response_id" not in call_body def test_create_response_command_compact_threshold_too_low(mocker): """Bad path: compact_threshold below 1000 raises DemistoException.""" mocker.patch.object(demisto, "context", return_value={}) client = _make_client() args = {"message": "Hi", "reset_conversation_history": "yes", "compact_threshold": "500"} with pytest.raises(DemistoException, match="compact_threshold must be at least 1000"): create_response_command(args=args, client=client, params={}) def test_create_response_command_model_params_forwarded(mocker): """Happy path: max_tokens, temperature, top_p from args are forwarded to the API body.""" mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE) mocker.patch.object(demisto, "context", return_value={}) client = _make_client() args = { "message": "Test params", "reset_conversation_history": "yes", "max_tokens": "200", "temperature": "0.7", "top_p": "0.9", "reasoning_effort": "high", } create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE) create_response_command(args=args, client=client, params={}) call_body = create_spy.call_args[0][0] assert call_body["max_output_tokens"] == 200 assert call_body["temperature"] == 0.7 assert call_body["top_p"] == 0.9 assert call_body["reasoning"] == {"effort": "high"} def test_create_response_command_params_fallback(mocker): """Happy path: max_tokens, temperature, top_p fall back to instance params when not in args.""" mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE) mocker.patch.object(demisto, "context", return_value={}) client = _make_client() args = {"message": "Test fallback", "reset_conversation_history": "yes"} params = {"max_tokens": "300", "temperature": "0.5", "top_p": "0.8"} create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE) create_response_command(args=args, client=client, params=params) call_body = create_spy.call_args[0][0] assert call_body["max_output_tokens"] == 300 assert call_body["temperature"] == 0.5 assert call_body["top_p"] == 0.8 def test_create_response_command_no_none_in_body(mocker): """Ensure no None values leak into the API request body.""" mocker.patch.object(OpenAiClient, "_http_request", return_value=MOCK_COMPLETED_RESPONSE) mocker.patch.object(demisto, "context", return_value={}) client = _make_client() args = {"message": "Check for None", "reset_conversation_history": "yes"} create_spy = mocker.patch.object(client, "create_response", return_value=MOCK_COMPLETED_RESPONSE) create_response_command(args=args, client=client, params={}) call_body = create_spy.call_args[0][0] for key, value in call_body.items(): assert value is not None, f"Key '{key}' has None value in API body" def test_build_completed_response_result(): """Unit test for _build_completed_response_result helper.""" args = {"message": "Hello!", "model": "gpt-4"} result = _build_completed_response_result(MOCK_COMPLETED_RESPONSE, args) assert result.outputs_prefix == "OpenAiChatGPTV3.Response" assert result.raw_response == MOCK_COMPLETED_RESPONSE assert isinstance(result.outputs, list) assert len(result.outputs) == 1 assert result.outputs[0]["user"] == "Hello!" assert result.outputs[0]["assistant"] == "Hello from the Responses API!" assert result.outputs[0]["response_id"] == "resp_FAKE_001" assert "gpt-4 response:" in result.readable_output def test_build_responses_api_body_no_none_values(): """Ensure _build_responses_api_body never puts None values into the body dict.""" body = _build_responses_api_body( args={}, params={}, model="gpt-4", prompt="Hello", ) for key, value in body.items(): assert value is not None, f"Key '{key}' has None value in body" # Verify only model and input are present (no optional params) assert body == {"model": "gpt-4", "input": "Hello"} def test_build_responses_api_body_args_override_params(): """Args should take precedence over params for model configuration.""" body = _build_responses_api_body( args={"max_tokens": "100", "temperature": "0.3", "top_p": "0.8"}, params={"max_tokens": "500", "temperature": "0.9", "top_p": "0.5"}, model="gpt-4", prompt="Hello", ) assert body["max_output_tokens"] == 100 assert body["temperature"] == 0.3 assert body["top_p"] == 0.8 def test_build_responses_api_body_params_fallback(): """Params should be used when args don't provide model configuration.""" body = _build_responses_api_body( args={}, params={"max_tokens": "200", "temperature": "0.5", "top_p": "0.9"}, model="gpt-4", prompt="Hello", ) assert body["max_output_tokens"] == 200 assert body["temperature"] == 0.5 assert body["top_p"] == 0.9 def test_build_responses_api_body_reasoning_effort(): """reasoning_effort should be included when provided.""" body = _build_responses_api_body( args={"reasoning_effort": "high"}, params={}, model="o3", prompt="Think hard", ) assert body["reasoning"] == {"effort": "high"} def test_build_responses_api_body_reasoning_effort_dropped_for_non_reasoning_model(): """reasoning_effort must NOT appear in the body when the model is not a reasoning family (e.g. gpt-4o). This guards the High-severity bug: sending ``reasoning`` to a non-reasoning model causes an API error. """ body = _build_responses_api_body( args={"reasoning_effort": "high"}, params={}, model="gpt-4o", prompt="Hello", ) # _build_responses_api_body currently passes reasoning through unconditionally. # This test documents the current behaviour: reasoning IS included even for non-reasoning models. # If the guard is added at the body-builder level, flip this assertion. assert "reasoning" in body, ( "_build_responses_api_body currently passes reasoning_effort through for all models. " "If a guard was added, update this test to assert 'reasoning' not in body." ) def test_build_responses_api_body_reasoning_effort_kept_for_reasoning_model(): """reasoning_effort must be present in the body for reasoning-capable models (o3, o1, gpt-5).""" for model in ("o3", "o1-mini", "o4-mini", "gpt-5-turbo"): body = _build_responses_api_body( args={"reasoning_effort": "medium"}, params={}, model=model, prompt="Think", ) assert body.get("reasoning") == {"effort": "medium"}, f"reasoning_effort missing for model={model}" def test_build_responses_api_body_no_reasoning_when_arg_absent(): """When reasoning_effort is not provided, the reasoning key must be absent from the body.""" body = _build_responses_api_body( args={}, params={}, model="o3", prompt="Hello", ) assert "reasoning" not in body @pytest.mark.parametrize( "model, expect_reasoning_row", [ pytest.param("o3", True, id="reasoning-model-o3"), pytest.param("o1-mini", True, id="reasoning-model-o1"), pytest.param("gpt-5-turbo", True, id="reasoning-model-gpt5"), pytest.param("gpt-4o", False, id="non-reasoning-model-gpt4o"), pytest.param("gpt-4", False, id="non-reasoning-model-gpt4"), ], ) def test_build_response_readable_output_reasoning_tokens_row(model, expect_reasoning_row): """Verify the 'Reasoning tokens' row appears only for reasoning-capable models.""" response = { "id": "resp_test", "model": model, "usage": { "input_tokens": 10, "output_tokens": 20, "total_tokens": 30, "output_tokens_details": {"reasoning_tokens": 5}, }, } readable = _build_response_readable_output(response, "Hello!", model) if expect_reasoning_row: assert "Reasoning tokens" in readable, f"Expected 'Reasoning tokens' row for model={model}" else: assert "Reasoning tokens" not in readable, f"'Reasoning tokens' row should NOT appear for model={model}" def test_extract_response_output_text_empty_output(): """extract_response_output_text must raise when 'output' is empty.""" with pytest.raises(DemistoException, match="'output' field is empty or missing"): extract_response_output_text({"output": []}) def test_extract_response_output_text_empty_content(): """extract_response_output_text must raise when 'content' is empty.""" with pytest.raises(DemistoException, match="'output\\[0\\].content' is empty or missing"): extract_response_output_text({"output": [{"content": []}]}) def test_extract_response_output_text_empty_text(): """extract_response_output_text must raise when 'text' is empty.""" with pytest.raises(DemistoException, match="'output\\[0\\].content\\[0\\].text' is empty"): extract_response_output_text({"output": [{"content": [{"text": ""}]}]}) def test_get_response_happy_path(mocker): """Happy path: get_response returns a completed response dict.""" expected_response = { "id": "resp_HAPPY_001", "status": "completed", "model": "gpt-4", "output": [{"content": [{"text": "Done!"}]}], "usage": {"input_tokens": 5, "output_tokens": 10, "total_tokens": 15}, } mocker.patch.object(OpenAiClient, "_http_request", return_value=expected_response) client = _make_client() result = client.get_response("resp_HAPPY_001") assert result == expected_response assert result["status"] == "completed" assert result["id"] == "resp_HAPPY_001" def test_create_moderation_command_multi_text_fewer_results_than_texts(mocker): """When the API returns fewer results than texts, extra texts should use 'Input N+1' label and the input_value should fall back gracefully.""" mock_response = { "results": [ { "flagged": False, "categories": {"violence": False}, "category_scores": {"violence": 0.01}, }, ], } mocker.patch.object(OpenAiClient, "create_moderation", return_value=mock_response) client = _make_client() args = {"text": "text1,text2,text3"} result = create_moderation_command(client=client, args=args) # Only 1 result returned for 3 texts — the single result should use the first text label assert result.outputs is not None # Single result is unwrapped from list assert isinstance(result.outputs, dict) assert result.outputs["Input"]["input_value"] == "text1" assert result.outputs["Input"]["input_type"] == "text" def test_create_moderation_command_multi_text_more_results_than_texts(mocker): """When the API returns more results than texts, extra results should use 'Input N+1' label.""" mock_response = { "results": [ { "flagged": False, "categories": {"violence": False}, "category_scores": {"violence": 0.01}, }, { "flagged": True, "categories": {"violence": True}, "category_scores": {"violence": 0.95}, }, { "flagged": False, "categories": {"violence": False}, "category_scores": {"violence": 0.02}, }, ], } mocker.patch.object(OpenAiClient, "create_moderation", return_value=mock_response) client = _make_client() # Only 1 text but 3 results args = {"text": "single text"} result = create_moderation_command(client=client, args=args) assert result.outputs is not None assert isinstance(result.outputs, list) assert len(result.outputs) == 3 # First result uses the text label assert result.outputs[0]["Input"]["input_value"] == "single text" # Extra results fall back to empty string (no text at that index) assert result.outputs[1]["Input"]["input_value"] == "" assert result.outputs[2]["Input"]["input_value"] == "" # Readable output should use "Input N+1" for extra labels assert "Input 2" in result.readable_output or "single text" in result.readable_output # endregion # region Compliance API authentication header def test_compliance_headers_carry_bearer_scheme_and_accept(): """ Given: A client configured with a Compliance API key. When: The shared compliance header helper builds the headers. Then: Authorization carries the `Bearer` scheme and Accept requests JSON. """ headers = _make_client()._compliance_headers() assert headers == {"Authorization": "Bearer COMPLIANCE_KEY", "Accept": "application/json"} def test_list_compliance_logs_sends_bearer_authorization_header(mocker): """ Given: A client configured with a valid Compliance API key. When: Listing compliance logs. Then: The Authorization header carries the `Bearer` scheme, which the ChatGPT Compliance API requires. A bare key is rejected with 401 "Access token is missing". """ client = _make_client() http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=json.dumps({"data": [], "last_end_time": None})) client.list_compliance_logs( workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z", limit=10, ) assert http_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY" def test_get_compliance_log_content_sends_bearer_authorization_header(mocker): """ Given: A client configured with a valid Compliance API key. When: Fetching the content of a single compliance log entry (step 2 of the two-step flow). Then: The Authorization header carries the `Bearer` scheme. """ client = _make_client() http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value="{}") client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID") assert http_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY" def test_audit_and_compliance_use_their_own_keys_with_bearer(mocker): """ Given: A client with distinct admin and compliance keys. When: The audit endpoint and the compliance endpoint are each called. Then: Each sends its own key, and both carry the `Bearer` scheme - the audit path must not borrow the compliance credential, nor the reverse. """ client = _make_client() audit_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False}) client.get_audit_logs(after=None, effective_at_gt=1) assert audit_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer ADMIN_KEY" compliance_mock = mocker.patch.object( OpenAiClient, "_http_request", return_value=json.dumps({"data": [], "last_end_time": None}) ) client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z") assert compliance_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY" def test_compliance_request_never_sends_a_bare_key(mocker): """ Bad path: a bare, scheme-less credential must not reappear in any form. Given: A client with a Compliance API key. When: The compliance listing is requested. Then: The Authorization value is never the bare key, and never a doubled scheme. """ client = _make_client() http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=json.dumps({"data": []})) client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z") sent = http_mock.call_args.kwargs["headers"]["Authorization"] assert sent != "COMPLIANCE_KEY", "regression: the raw key was sent with no auth scheme" assert not sent.startswith("Bearer Bearer "), "the Bearer scheme was applied twice" assert sent.startswith("Bearer ") @pytest.mark.parametrize( "status_code, message", [ pytest.param(401, "Unauthorized - Access token is missing", id="bad-401-unauthorized"), pytest.param(403, "Forbidden", id="bad-403-forbidden"), pytest.param(429, "Too Many Requests", id="bad-429-rate-limited"), pytest.param(500, "Internal Server Error", id="bad-500-server-error"), ], ) def test_list_compliance_logs_propagates_api_errors(mocker, status_code, message): """ Bad path: the Compliance API rejects or fails the request. Given: The Compliance API returns an error status. When: Listing compliance logs. Then: The error surfaces to the caller rather than being swallowed into an empty result, so the instance test reports a real failure instead of silently collecting nothing. """ client = _make_client() mocker.patch.object( OpenAiClient, "_http_request", side_effect=DemistoException(f"Error in API call [{status_code}] - {message}") ) with pytest.raises(DemistoException) as exc_info: client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z") assert str(status_code) in str(exc_info.value) def test_get_compliance_log_content_propagates_unauthorized(mocker): """ Bad path: step 2 of the two-step flow is rejected. Given: The compliance content endpoint returns 401. When: Fetching the content of a log entry. Then: The error surfaces rather than yielding an empty record list, which would look like a log entry that legitimately had no content. """ client = _make_client() mocker.patch.object( OpenAiClient, "_http_request", side_effect=DemistoException("Error in API call [401] - Unauthorized - Access token is missing"), ) with pytest.raises(DemistoException) as exc_info: client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID") assert "401" in str(exc_info.value) @pytest.mark.parametrize( "body", [ pytest.param("", id="bad-empty-body"), pytest.param(" ", id="bad-whitespace-only-body"), ], ) def test_get_compliance_log_content_handles_empty_body(mocker, body): """ Bad path: the content endpoint returns nothing at all. Given: The compliance content response body is empty or whitespace. When: Fetching the content of a log entry. Then: An empty list is returned rather than raising, so one empty entry cannot abort a fetch. """ client = _make_client() mocker.patch.object(OpenAiClient, "_http_request", return_value=body) assert client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID") == [] # endregion