KOI

KOI is an endpoint security platform that provides visibility and control over browser extensions, SaaS applications, and web-based threats.

Endpoint · KOI

Details

IDKOI
ProviderKOI
CategoryEndpoint
From Version6.10.0
Docker Imagedemisto/fastapi:0.125.0.10158186

README

Commands

You can execute these commands from the CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.

koi-get-events


Gets events from KOI. This command is used for developing/debugging. Use with caution, as it can create events, leading to event duplication and exceeding API request limitations.

Base Command

koi-get-events

Input

Argument Name Description Required
event_type The type of events to retrieve. If not specified, uses the value configured in the integration parameters. Possible values are: Alerts, Audit. Default is Alerts,Audit. Optional
limit The maximum number of events to return per type. Default is 50. Optional
start_time Filter events created at or after this time. Supports ISO 8601 format or relative time expressions (e.g., “3 days ago”, “2024-01-01T00:00:00Z”). Optional
end_time Filter events created at or before this time. Supports ISO 8601 format or relative time expressions (e.g., “now”, “2024-01-01T00:00:00Z”). Optional
should_push_events The flag that indicates whether to push events to Cortex XSIAM. Pushing events is supported on Cortex XSIAM only. When set to false, or on non-XSIAM platforms, events are displayed without being pushed. Possible values are: true, false. Default is false. Optional

Context Output

Path Type Description
KOI.Event.id String The unique identifier of the event.
KOI.Event.source_log_type String The source log type of the event (Alerts or Audit).
KOI.Event._time Date The timestamp of the event in ISO 8601 format.
KOI.Event.created_at Date The creation time of the event (audit logs).

Human Readable Output

KOI Events

id source_log_type _time severity status
alert-001 Alerts 2024-01-01T00:00:00Z high open
audit-001 Audit 2024-01-01T00:00:00Z    

koi-blocklist-get


Retrieves all items in the blocklist.

Base Command

koi-blocklist-get

Input

There are no input arguments for this command.

Context Output

Path Type Description
Koi.Blocklist.item_id String The unique identifier of the blocklist item.
Koi.Blocklist.item_name String The name of the blocklist item.
Koi.Blocklist.item_display_name String The display name of the blocklist item.
Koi.Blocklist.marketplace String The marketplace of the blocklist item (e.g., vscode).
Koi.Blocklist.publisher_name String The publisher name of the blocklist item.
Koi.Blocklist.package_name String The package name of the blocklist item.
Koi.Blocklist.notes String Notes associated with the blocklist item.
Koi.Blocklist.created_by String The user who created the blocklist item.
Koi.Blocklist.created_at Date The creation time of the blocklist item in ISO 8601 format.

Command example

!koi-blocklist-get

Human Readable Output

KOI Blocklist

Item Id Item Name Item Display Name Marketplace Publisher Name Package Name Notes Created By Created At
mal-001 Bad Extension Malicious Extension chrome_web_store Suspicious Publisher bad-package Known malware distribution security@example.com 2025-05-01T09:15:00.000Z
mal-002 Risky Plugin Risky Plugin vscode Unknown Publisher risky-plugin Data exfiltration risk admin@example.com 2025-05-02T14:30:00.000Z

koi-allowlist-get


Retrieves all items in the allowlist.

Base Command

koi-allowlist-get

Input

There are no input arguments for this command.

Context Output

Path Type Description
Koi.Allowlist.item_id String The unique identifier of the allowlist item.
Koi.Allowlist.item_name String The name of the allowlist item.
Koi.Allowlist.item_display_name String The display name of the allowlist item.
Koi.Allowlist.marketplace String The marketplace of the allowlist item (e.g., vscode).
Koi.Allowlist.publisher_name String The publisher name of the allowlist item.
Koi.Allowlist.package_name String The package name of the allowlist item.
Koi.Allowlist.notes String Notes associated with the allowlist item.
Koi.Allowlist.created_by String The user who created the allowlist item.
Koi.Allowlist.created_at Date The creation time of the allowlist item in ISO 8601 format.

Command example

!koi-allowlist-get

Human Readable Output

KOI Allowlist

Item Id Item Name Item Display Name Marketplace Publisher Name Package Name Notes Created By Created At
ext-123 My Extension My Extension Display Name vscode My Publisher my-package Approved for development purposes admin@example.com 2025-04-23T17:22:24.023Z
ext-456 Another Ext Another Extension chrome Another Publisher another-package Approved by security team user@example.com 2025-04-24T10:00:00.000Z

koi-inventory-search


Searches inventory items using advanced query builder filters. Provide a filter via the ‘filter_json’ argument (inline JSON string) or the ‘filter_raw_json_entry_id’ argument (War Room file entry ID). At least one filter source must be provided.

Base Command

koi-inventory-search

Input

Argument Name Description Required
filter_json Advanced filter using query builder syntax as a JSON string. Either the ‘filter_json’ or the ‘filter_raw_json_entry_id’ argument must be provided. Optional
filter_raw_json_entry_id War Room entry ID of a JSON file containing the filter object. Takes priority over the ‘filter_json’ argument when both are provided. Optional
page Page number for pagination (1-based). When provided, fetches a single page and ignores the ‘limit’ argument. Optional
page_size Number of results per page (default: 50, max: 500). Used in single-page mode with the ‘page’ argument. Optional
limit Maximum total number of inventory items to return (default: 50, max: 1000). When provided without the ‘page’ argument, auto-paginates to collect up to this many items. Default is 50. Optional
sort_by Column to sort by. Possible values are: first_seen, last_seen, item_display_name, item_id, version, marketplace, endpoint_count, risk, risk_level, status, installs_count, released_at, publisher_name. Default is first_seen. Optional
sort_direction Sort direction. Possible values are: asc, desc. Default is desc. Optional

Context Output

Path Type Description
Koi.Inventory.item_id String The unique identifier of the inventory item.
Koi.Inventory.item_display_name String The display name of the inventory item.
Koi.Inventory.marketplace String The marketplace source of the item.
Koi.Inventory.platforms Unknown List of platforms where the item is installed.
Koi.Inventory.publisher_name String The publisher name of the item.
Koi.Inventory.risk Number The numeric risk score of the item.
Koi.Inventory.risk_level String The risk level classification of the item.
Koi.Inventory.version String The version of the item.
Koi.Inventory.status String The governance status of the item.
Koi.Inventory.endpoint_count Number The number of endpoints where the item is installed.
Koi.Inventory.installs_count Number The total number of installs for the item.
Koi.Inventory.first_seen Date The date the item was first seen in ISO 8601 format.
Koi.Inventory.last_seen Date The date the item was last seen in ISO 8601 format.
Koi.Inventory.last_used Date The date the item was last used in ISO 8601 format.
Koi.Inventory.installation_method String The method used to install the item.
Koi.Inventory.short_description String A short description of the item.
Koi.Inventory.is_first_party Boolean Whether the item is a first-party item.
Koi.Inventory.is_signed Boolean Whether the item is signed.
Koi.Inventory.categories Unknown List of categories the item belongs to.
Koi.Inventory.findings Unknown List of findings associated with the item.
Koi.Inventory.governed_details Unknown Governance policy details for the item.
Koi.Inventory.released_at Date The release date of the item. Format: YYYY-MM-DD (e.g., 2023-01-15).
Koi.Inventory.brew_category_koi String The Homebrew package category (Koi classification).
Koi.Inventory.browser_category_koi String The browser extension category (Koi classification).
Koi.Inventory.chocolatey_category_koi String The Chocolatey package category (Koi classification).
Koi.Inventory.ide_category_koi String The IDE extension category (Koi classification).
Koi.Inventory.software_category_koi String The software category (Koi classification).

Command example

!koi-inventory-search filter_json="{\"field\":\"risk_level\",\"operator\":\"eq\",\"value\":\"high\"}" limit=50

Human Readable Output

KOI Inventory Search

Item Id Item Display Name Marketplace Platforms Publisher Name Risk Risk Level Version Status Endpoint Count Installs Count Installation Method Is First Party Is Signed First Seen Last Seen Last Used Released At Short Description Categories Findings
abc123 React Developer Tools chrome_web_store chrome, edge Meta 5 high 1.0.0 APPROVED 42 1000000 marketplace false true 2024-01-01T10:00:00Z 2024-10-15T10:00:00Z 2025-06-15T10:00:00Z 2023-01-15 React debugging tools Developer Tools malware, permissions

koi-policy-list


Retrieves a list of all policies. Use the ‘page’ and ‘page_size’ arguments to fetch a specific page, or use the ‘limit’ argument to auto-paginate and collect up to the specified number of policies. If the ‘page’ argument is provided, the ‘limit’ argument is ignored.

Base Command

koi-policy-list

Input

Argument Name Description Required
page Page number for pagination (1-based). When provided, fetches a single page and ignores the ‘limit’ argument. Optional
page_size Number of results per page (default: 50, max: 500). Used only in single-page mode together with the ‘page’ argument. Optional
limit Maximum total number of policies to return (default: 50, max: 1000). When provided without the ‘page’ argument, auto-paginates to collect up to this many policies. Default is 50. Optional

Context Output

Path Type Description
Koi.Policy.id Number The unique identifier of the policy.
Koi.Policy.name String The name of the policy.
Koi.Policy.description String The description of the policy.
Koi.Policy.action String The action taken by the policy (e.g., block).
Koi.Policy.enabled Boolean Whether the policy is enabled.
Koi.Policy.group_ids Unknown List of group IDs associated with the policy.
Koi.Policy.creator_fullname String The full name of the policy creator.
Koi.Policy.created_at Date The creation time of the policy in ISO 8601 format.
Koi.Policy.updated_at Date The last update time of the policy in ISO 8601 format.

Command example

!koi-policy-list limit=50

Human Readable Output

KOI Policies

Id Name Description Action Enabled Group Ids Creator Fullname Created At Updated At
1 My Policy This policy blocks high-risk extensions block true 1, 2, 3 John Doe 2025-04-23T17:22:24.023Z 2025-04-23T17:22:24.023Z
2 Allow Policy This policy allows approved extensions allow false 4 Jane Smith 2025-04-24T10:00:00.000Z 2025-04-24T12:30:00.000Z

koi-inventory-item-get


Retrieves comprehensive details for a specific software item, extension, or package using its unique identifier, marketplace, and version.

Base Command

koi-inventory-item-get

Input

Argument Name Description Required
item_id Unique identifier for the item. Required
marketplace The marketplace where the item is hosted. Possible values are: chocolatey, chrome_web_store, claude_desktop_extensions, cursor, docker, edge_add_ons, firefox_add_ons, github_mcp_registry, homebrew, hugging_face, jetbrains, linux, mac, notepad++, npm, office_add_ins, open_vsx_registry, pypi, visual_studio, vscode, windows, windsurf. Required
version The specific version of the item to retrieve. Required

Context Output

Path Type Description
Koi.Inventory.item_id String The unique identifier of the inventory item.
Koi.Inventory.item_display_name String The display name of the inventory item.
Koi.Inventory.marketplace String The marketplace source of the item.
Koi.Inventory.platforms Unknown List of platforms where the item is installed.
Koi.Inventory.publisher_name String The publisher name of the item.
Koi.Inventory.risk Number The numeric risk score of the item.
Koi.Inventory.risk_level String The risk level classification of the item.
Koi.Inventory.version String The version of the item.
Koi.Inventory.status String The governance status of the item.
Koi.Inventory.endpoint_count Number The number of endpoints where the item is installed.
Koi.Inventory.installs_count Number The total number of installs for the item.
Koi.Inventory.installation_method String The method used to install the item.
Koi.Inventory.is_first_party Boolean Whether the item is a first-party item.
Koi.Inventory.is_signed Boolean Whether the item is signed.
Koi.Inventory.first_seen Date The date the item was first seen in ISO 8601 format.
Koi.Inventory.last_seen Date The date the item was last seen in ISO 8601 format.
Koi.Inventory.last_used Date The date the item was last used in ISO 8601 format.
Koi.Inventory.released_at Date The release date of the item. Format: YYYY-MM-DD (e.g., 2023-01-15).
Koi.Inventory.short_description String A short description of the item.
Koi.Inventory.categories Unknown List of categories the item belongs to.
Koi.Inventory.findings Unknown List of findings associated with the item including severity and evidence.
Koi.Inventory.governed_details Unknown Governance policy details for the item.
Koi.Inventory.brew_category_koi String The Homebrew package category (Koi classification).
Koi.Inventory.browser_category_koi String The browser extension category (Koi classification).
Koi.Inventory.chocolatey_category_koi String The Chocolatey package category (Koi classification).
Koi.Inventory.ide_category_koi String The IDE extension category (Koi classification).
Koi.Inventory.software_category_koi String The software category (Koi classification).

Command example

!koi-inventory-item-get item_id=example-extension marketplace=vscode

Human Readable Output

KOI Inventory Item

Item Id Item Display Name Marketplace Platforms Publisher Name Risk Risk Level Version Status Endpoint Count Installs Count Installation Method Is First Party Is Signed First Seen Last Seen Last Used Released At Short Description Categories Findings
abc123 React Developer Tools chrome_web_store chrome, edge Meta 5 high 1.0.0 Allowed 42 1000000 marketplace false true 2024-01-01T10:00:00Z 2024-10-15T10:00:00Z 2025-06-15T10:00:00Z 2023-01-15 React debugging tools Developer Tools {‘description’: ‘This item contains malware’, ‘evidence’: {}, ‘finding_id’: ‘malware_detected’, ‘finding_name’: ‘Malware Detected’, ‘severity’: ‘critical’}

koi-blocklist-items-add


Adds one or more items to the global blocklist. Provide either the ‘item_id’ and ‘marketplace’ arguments for a single item, or the ‘items_list_raw_json_entry_id’ argument for bulk addition from a JSON file.

Base Command

koi-blocklist-items-add

Input

Argument Name Description Required
item_id The ID of the item to add to the blocklist. Required when not using the ‘items_list_raw_json_entry_id’ argument. Optional
marketplace The source marketplace of the item. Required when not using the ‘items_list_raw_json_entry_id’ argument. Possible values are: chocolatey, chrome_web_store, claude_desktop_extensions, cursor, docker, edge_add_ons, firefox_add_ons, github_mcp_registry, homebrew, hugging_face, jetbrains, linux, mac, notepad++, npm, office_add_ins, open_vsx_registry, pypi, visual_studio, vscode, windows, windsurf. Optional
created_by Email of the user who created this entry. Optional
notes Additional notes or justification for blocking the item. Optional
items_list_raw_json_entry_id War Room entry ID of a JSON file containing a list of items to add. Each item must have “item_id” and “marketplace” fields. Optional fields: “created_by”, “notes”. When provided, the ‘item_id’ and ‘marketplace’ arguments are ignored. Optional

Context Output

There is no context output for this command.

Command example

!koi-blocklist-items-add item_id=malicious-ext marketplace=chrome_web_store notes="Blocked due to security risk"

Human Readable Output

Blocklist item ‘malicious-ext’ (marketplace: chrome_web_store) was added successfully.

koi-policy-status-update


Enables or disables a policy by ID.

Base Command

koi-policy-status-update

Input

Argument Name Description Required
policy_id The ID of the policy to update. Required
enabled Whether to enable (true) or disable (false) the policy. Possible values are: true, false. Required

Context Output

Path Type Description
Koi.Policy.id Number The unique identifier of the policy.
Koi.Policy.name String The name of the policy.
Koi.Policy.description String The description of the policy.
Koi.Policy.action String The action taken by the policy (e.g., block).
Koi.Policy.enabled Boolean Whether the policy is enabled.
Koi.Policy.group_ids Unknown List of group IDs associated with the policy.
Koi.Policy.creator_fullname String The full name of the policy creator.
Koi.Policy.created_at Date The creation time of the policy in ISO 8601 format.
Koi.Policy.updated_at Date The last update time of the policy in ISO 8601 format.

Command example

!koi-policy-status-update policy_id=1 enabled=true

Human Readable Output

KOI Policy Updated

Id Name Description Action Enabled Group Ids Creator Fullname Created At Updated At
1 My Policy This policy blocks high-risk extensions block true 1, 2, 3 John Doe 2025-04-23T17:22:24.023Z 2025-04-23T17:22:24.023Z

koi-inventory-list


Retrieves a paginated list of items installed across your organization’s endpoints. Supports extensive filtering by marketplace, platform, risk level, publisher, and specific categories.

Base Command

koi-inventory-list

Input

Argument Name Description Required
page Page number for pagination (1-based). When provided, fetches a single page and ignores the ‘limit’ argument. Optional
page_size Number of results per page (default: 50, max: 500). Used in single-page mode with the ‘page’ argument. Optional
limit Maximum total number of inventory items to return (default: 50, max: 1000). When provided without the ‘page’ argument, auto-paginates to collect up to this many items. Default is 50. Optional
brew_category_koi Filter by Homebrew package category (Koi classification). Optional
browser_category_koi Filter by browser extension category (Koi classification). Optional
chocolatey_category_koi Filter by Chocolatey package category (Koi classification). Optional
device_id Filter devices by device ID. Optional
finding_id Filter devices by finding ID. Optional
first_seen Filter by first seen date (items first seen on or after this date). ISO 8601 format (e.g., “2024-01-01T00:00:00Z”). Optional
ide_category_koi Filter by IDE extension category (Koi classification). Optional
installation_method Filter by installation method. Possible values are: marketplace, manual, built_in, side_loaded. Optional
item_display_name Filter by item display name. Performs case-insensitive partial match. Optional
item_id Filter by item ID. Optional
marketplace Filter by marketplace. Possible values are: chocolatey, chrome_web_store, claude_desktop_extensions, cursor, docker, edge_add_ons, firefox_add_ons, github_mcp_registry, homebrew, hugging_face, jetbrains, linux, mac, notepad++, npm, office_add_ins, open_vsx_registry, pypi, visual_studio, vscode, windows, windsurf. Optional
platform Filter by platform. Possible values are: antigravity, aqua, arc, brave, brew, chatgpt_atlas, chocolatey, chrome, chromium, claude, clion, codex, comet, cursor, datagrip, dataspell, dia, edge, excel, firefox, fleet, goland, hugging_face, intellij_community, intellij, kiro, mac, npm, notepad++, opera, outlook, phpstorm, powerpoint, prisma_access_browser, pycharm, pypi, rider, rubymine, rustrover, vscode, webstorm, windsurf, word, windows, writerside. Optional
publisher_name Filter by publisher name. Performs case-insensitive partial match. Optional
risk_level Filter by risk level. Possible values are: low, medium, high, critical, pending. Optional
software_category_koi Filter by software category (Koi classification). Optional
sort_by Column to sort by. Possible values are: first_seen, last_seen, item_display_name, item_id, version, marketplace, endpoint_count, risk, risk_level, status, installs_count, released_at, publisher_name. Default is first_seen. Optional
sort_direction Sort direction. Possible values are: asc, desc. Optional
view Filter by predefined view (marketplace group). Possible values are: agentic_ai, ai_models, code_packages, extensions, os_packages, software. Optional

Context Output

Path Type Description
Koi.Inventory.item_id String The unique identifier of the inventory item.
Koi.Inventory.item_display_name String The display name of the inventory item.
Koi.Inventory.marketplace String The marketplace source of the item.
Koi.Inventory.platforms Unknown List of platforms where the item is installed.
Koi.Inventory.publisher_name String The publisher name of the item.
Koi.Inventory.risk Number The numeric risk score of the item.
Koi.Inventory.risk_level String The risk level classification of the item.
Koi.Inventory.version String The version of the item.
Koi.Inventory.status String The governance status of the item.
Koi.Inventory.endpoint_count Number The number of endpoints where the item is installed.
Koi.Inventory.installs_count Number The total number of installs for the item.
Koi.Inventory.first_seen Date The date the item was first seen in ISO 8601 format.
Koi.Inventory.last_seen Date The date the item was last seen in ISO 8601 format.
Koi.Inventory.last_used Date The date the item was last used in ISO 8601 format.
Koi.Inventory.installation_method String The method used to install the item.
Koi.Inventory.short_description String A short description of the item.
Koi.Inventory.is_first_party Boolean Whether the item is a first-party item.
Koi.Inventory.is_signed Boolean Whether the item is signed.
Koi.Inventory.categories Unknown List of categories the item belongs to.
Koi.Inventory.findings Unknown List of findings associated with the item.
Koi.Inventory.governed_details Unknown Governance policy details for the item.
Koi.Inventory.released_at Date The release date of the item. Format: YYYY-MM-DD (e.g., 2023-01-15).
Koi.Inventory.brew_category_koi String The Homebrew package category (Koi classification).
Koi.Inventory.browser_category_koi String The browser extension category (Koi classification).
Koi.Inventory.chocolatey_category_koi String The Chocolatey package category (Koi classification).
Koi.Inventory.ide_category_koi String The IDE extension category (Koi classification).
Koi.Inventory.software_category_koi String The software category (Koi classification).

Command example

!koi-inventory-list limit=50 marketplace=vscode sort_by=first_seen sort_direction=desc

Human Readable Output

KOI Inventory

Item Id Item Display Name Marketplace Platforms Publisher Name Risk Risk Level Version Status Endpoint Count Installs Count Installation Method Is First Party Is Signed First Seen Last Seen Last Used Released At Short Description Categories Findings
abc123 React Developer Tools chrome_web_store chrome, edge Meta 5 high 1.0.0 APPROVED 42 1000000 marketplace false true 2024-01-01T10:00:00Z 2024-10-15T10:00:00Z 2025-06-15T10:00:00Z 2023-01-15 React debugging tools Developer Tools malware, permissions
def456 Prettier - Code formatter vscode vscode Prettier 2 low 10.1.0 APPROVED 15 500000 manual true true 2024-03-10T08:30:00Z 2024-11-01T14:00:00Z   2022-06-01 Code formatter using prettier Productivity  

koi-inventory-item-endpoints-list


Retrieves a paginated list of endpoints that have a specific item installed.

Base Command

koi-inventory-item-endpoints-list

Input

Argument Name Description Required
item_id Unique identifier for the item. Required
marketplace The marketplace where the item is hosted. Possible values are: chocolatey, chrome_web_store, claude_desktop_extensions, cursor, docker, edge_add_ons, firefox_add_ons, github_mcp_registry, homebrew, hugging_face, jetbrains, linux, mac, notepad++, npm, office_add_ins, open_vsx_registry, pypi, visual_studio, vscode, windows, windsurf. Required
version The specific version of the item. Required
page Page number for pagination (1-based). When provided, fetches a single page and ignores the ‘limit’ argument. Optional
page_size Number of results per page (default: 50, max: 500). Used in single-page mode with the ‘page’ argument. Optional
limit Maximum total number of endpoints to return (default: 50, max: 1000). When provided without the ‘page’ argument, auto-paginates to collect up to this many endpoints. Default is 50. Optional

Context Output

Path Type Description
Koi.Inventory.Endpoint.id String The unique identifier of the endpoint device.
Koi.Inventory.Endpoint.hostname String The hostname of the endpoint.
Koi.Inventory.Endpoint.os String The operating system of the endpoint.
Koi.Inventory.Endpoint.platform String The platform where the item is installed on this endpoint.
Koi.Inventory.Endpoint.serial String The serial number of the endpoint device.
Koi.Inventory.Endpoint.last_logged_on_user String The last logged on user of the endpoint.
Koi.Inventory.Endpoint.activation_status String The activation status of the endpoint.
Koi.Inventory.Endpoint.path String The installation path of the item on the endpoint.
Koi.Inventory.Endpoint.first_seen Date The date the item was first seen on this endpoint in ISO 8601 format.
Koi.Inventory.Endpoint.last_seen Date The date the item was last seen on this endpoint in ISO 8601 format.

Command example

!koi-inventory-item-endpoints-list item_id=example-extension marketplace=vscode limit=50

Human Readable Output

KOI Inventory Item Endpoints

Id Hostname Os Platform Serial Last Logged On User Activation Status Path First Seen Last Seen
device-123 laptop-01 windows chrome ABC123XYZ john.doe enabled /Applications/Google Chrome.app/Contents/Extensions/abc123 2024-01-01T10:00:00Z 2024-10-15T10:00:00Z
device-456 desktop-02 macos chrome DEF456UVW jane.smith enabled /Users/jane/Library/Application Support/Google/Chrome/Extensions/abc123 2024-02-15T08:30:00Z 2024-11-01T14:00:00Z

koi-blocklist-items-remove


Removes one or more items from the global blocklist. Provide either the ‘item_id’ and ‘marketplace’ arguments for a single item, or the ‘items_list_raw_json_entry_id’ argument for bulk removal from a JSON file.

Base Command

koi-blocklist-items-remove

Input

Argument Name Description Required
item_id The ID of the item to remove from the blocklist. Required when not using the ‘items_list_raw_json_entry_id’ argument. Optional
marketplace The source marketplace of the item. Required when not using the ‘items_list_raw_json_entry_id’ argument. Possible values are: chocolatey, chrome_web_store, claude_desktop_extensions, cursor, docker, edge_add_ons, firefox_add_ons, github_mcp_registry, homebrew, hugging_face, jetbrains, linux, mac, notepad++, npm, office_add_ins, open_vsx_registry, pypi, visual_studio, vscode, windows, windsurf. Optional
created_by Email of the user who created this entry. Optional
notes Additional notes about the removal. Optional
items_list_raw_json_entry_id War Room entry ID of a JSON file containing a list of items to remove. Each item must have “item_id” and “marketplace” fields. Optional fields: “created_by”, “notes”. When provided, the ‘item_id’ and ‘marketplace’ arguments are ignored. Optional

Context Output

There is no context output for this command.

Command example

!koi-blocklist-items-remove item_id=malicious-ext marketplace=chrome_web_store

Human Readable Output

Blocklist item ‘malicious-ext’ (marketplace: chrome_web_store) was removed successfully.

koi-allowlist-items-remove


Removes one or more items from the global allowlist. Provide either the ‘item_id’ and ‘marketplace’ arguments for a single item, or the ‘items_list_raw_json_entry_id’ argument for bulk removal from a JSON file.

Base Command

koi-allowlist-items-remove

Input

Argument Name Description Required
item_id The ID of the item to remove from the allowlist. Required when not using the ‘items_list_raw_json_entry_id’ argument. Optional
marketplace The source marketplace of the item. Required when not using the ‘items_list_raw_json_entry_id’ argument. Possible values are: chocolatey, chrome_web_store, claude_desktop_extensions, cursor, docker, edge_add_ons, firefox_add_ons, github_mcp_registry, homebrew, hugging_face, jetbrains, linux, mac, notepad++, npm, office_add_ins, open_vsx_registry, pypi, visual_studio, vscode, windows, windsurf. Optional
created_by Email of the user who created this entry. Optional
notes Additional notes about the removal. Optional
items_list_raw_json_entry_id War Room entry ID of a JSON file containing a list of items to remove. Each item must have “item_id” and “marketplace” fields. Optional fields: “created_by”, “notes”. When provided, the ‘item_id’ and ‘marketplace’ arguments are ignored. Optional

Context Output

There is no context output for this command.

Command example

!koi-allowlist-items-remove item_id=example-extension marketplace=vscode

Human Readable Output

Allowlist item ‘example-extension’ (marketplace: vscode) was removed successfully.

koi-allowlist-items-add


Adds one or more items to the global allowlist. Provide either the ‘item_id’ and ‘marketplace’ arguments for a single item, or the ‘items_list_raw_json_entry_id’ argument for bulk addition from a JSON file.

Base Command

koi-allowlist-items-add

Input

Argument Name Description Required
item_id The ID of the item to add to the allowlist. Required when not using the ‘items_list_raw_json_entry_id’ argument. Optional
marketplace The source marketplace of the item. Required when not using the ‘items_list_raw_json_entry_id’ argument. Possible values are: chocolatey, chrome_web_store, claude_desktop_extensions, cursor, docker, edge_add_ons, firefox_add_ons, github_mcp_registry, homebrew, hugging_face, jetbrains, linux, mac, notepad++, npm, office_add_ins, open_vsx_registry, pypi, visual_studio, vscode, windows, windsurf. Optional
created_by Email of the user who created this entry. Optional
notes Additional notes about the entry. Optional
items_list_raw_json_entry_id War Room entry ID of a JSON file containing a list of items to add. Each item must have “item_id” and “marketplace” fields. Optional fields: “created_by”, “notes”. When provided, the ‘item_id’ and ‘marketplace’ arguments are ignored. Optional

Context Output

There is no context output for this command.

Command example

!koi-allowlist-items-add item_id=example-extension marketplace=vscode notes="Approved by security team"

Human Readable Output

Allowlist item ‘example-extension’ (marketplace: vscode) was added successfully.

Configuration parameters

  • url — Server URL (required)
  • api_key — API Key (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • isFetchEvents — Fetch events
  • event_types_to_fetch — Fetch event types (required)
  • audit_types_filter — Audit log type filter
  • max_fetch — Maximum number of events per fetch
  • eventFetchInterval — Events Fetch Interval

Commands (13)

  • koi-allowlist-get

    Retrieves all items in the allowlist.

  • koi-allowlist-items-add

    Adds one or more items to the global allowlist. Provide either 'item_id' and 'marketplace' for a single item, or 'items_list_raw_json_entry_id' for bulk addition from a JSON file.

  • koi-allowlist-items-remove

    Removes one or more items from the global allowlist. Provide either 'item_id' and 'marketplace' for a single item, or 'items_list_raw_json_entry_id' for bulk removal from a JSON file.

  • koi-blocklist-get

    Retrieves all items in the blocklist.

  • koi-blocklist-items-add

    Adds one or more items to the global blocklist. Provide either 'item_id' and 'marketplace' for a single item, or 'items_list_raw_json_entry_id' for bulk addition from a JSON file.

  • koi-blocklist-items-remove

    Removes one or more items from the global blocklist. Provide either 'item_id' and 'marketplace' for a single item, or 'items_list_raw_json_entry_id' for bulk removal from a JSON file.

  • koi-get-events

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

  • koi-inventory-item-endpoints-list

    Retrieves a paginated list of endpoints that have a specific item installed.

  • koi-inventory-item-get

    Retrieves comprehensive details for a specific software item, extension, or package using its unique identifier, marketplace, and version.

  • koi-inventory-list

    Retrieves a paginated list of items installed across your organization's endpoints. Supports extensive filtering by marketplace, platform, risk level, publisher, and specific categories.

  • koi-inventory-search

    Searches inventory items using advanced query builder filters. Provide a filter via 'filter_json' (inline JSON string) or 'filter_raw_json_entry_id' (War Room file entry ID). At least one filter source must be provided.

  • koi-policy-list

    Retrieves a list of all policies. Use 'page' and 'page_size' to fetch a specific page, or use 'limit' to auto-paginate and collect up to the specified number of policies. If 'page' is provided, 'limit' is ignored.

  • koi-policy-status-update

    Enables or disables a policy by ID.

import json
from datetime import datetime
from pathlib import Path

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

from Koi import (
    ApiPaths,
    Client,
    Config,
    LogType,
    VALID_AUDIT_TYPES,
    VALID_MARKETPLACES,
    COMMAND_MAP,
    get_log_types_from_titles,
    extract_time_from_event,
    add_time_to_events,
    get_event_id,
    deduplicate_events,
    fetch_events_with_pagination,
    test_module as koi_test_module,
    get_events_command,
    fetch_events_command,
    koi_policy_list_command,
    koi_allowlist_get_command,
    koi_allowlist_items_remove_command,
    koi_allowlist_items_add_command,
    koi_blocklist_get_command,
    koi_blocklist_items_remove_command,
    koi_blocklist_items_add_command,
    koi_policy_status_update_command,
    koi_inventory_list_command,
    koi_inventory_item_get_command,
    koi_inventory_search_command,
    koi_inventory_item_endpoints_list_command,
    parse_filter_from_args,
    resolve_items_from_args,
    parse_list_items_from_entry_id,
    get_formatted_utc_time,
    parse_date_or_use_current,
    main,
)


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


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


# endregion

# region Fixtures


@pytest.fixture
def alerts_response() -> dict:
    """Fixture for a mock alerts API response."""
    return load_test_data("alerts_response.json")


@pytest.fixture
def audit_response() -> dict:
    """Fixture for a mock audit logs API response."""
    return load_test_data("audit_response.json")


@pytest.fixture
def empty_response() -> dict:
    """Fixture for an empty API response."""
    return load_test_data("empty_response.json")


@pytest.fixture
def policies_response() -> dict:
    """Fixture for a mock policies API response."""
    return load_test_data("policies_response.json")


@pytest.fixture
def allowlist_response() -> dict:
    """Fixture for a mock allowlist API response."""
    return load_test_data("allowlist_response.json")


@pytest.fixture
def blocklist_response() -> dict:
    """Fixture for a mock blocklist API response."""
    return load_test_data("blocklist_response.json")


@pytest.fixture
def policy_update_response() -> dict:
    """Fixture for a mock policy update API response."""
    return load_test_data("policy_update_response.json")


@pytest.fixture
def inventory_response() -> dict:
    """Fixture for a mock inventory API response."""
    return load_test_data("inventory_response.json")


@pytest.fixture
def inventory_item_response() -> dict:
    """Fixture for a mock inventory item API response."""
    return load_test_data("inventory_item_response.json")


@pytest.fixture
def inventory_item_endpoints_response() -> dict:
    """Fixture for a mock inventory item endpoints API response."""
    return load_test_data("inventory_item_endpoints_response.json")


@pytest.fixture
def mock_client(mocker):
    """Fixture for a mocked Koi Client."""
    mocker.patch.object(Client, "__init__", return_value=None)
    client = Client.__new__(Client)
    return client


# endregion

# region get_log_types_from_titles tests


class TestGetLogTypesFromTitles:
    """Tests for the get_log_types_from_titles helper function."""

    @pytest.mark.parametrize(
        "titles, expected_type_strings",
        [
            (["Alerts"], ["alerts"]),
            (["Audit"], ["audit"]),
            (["Alerts", "Audit"], ["alerts", "audit"]),
        ],
        ids=["alerts_only", "audit_only", "both_types"],
    )
    def test_valid_titles(self, titles: list[str], expected_type_strings: list[str]):
        """Test converting valid user-facing titles to LogType enum members."""
        result = get_log_types_from_titles(titles)
        assert [lt.type_string for lt in result] == expected_type_strings

    @pytest.mark.parametrize(
        "titles",
        [
            (["InvalidType"]),
            (["Alerts", "BadType"]),
            ([""]),
        ],
        ids=["single_invalid", "mixed_invalid", "empty_string"],
    )
    def test_invalid_titles(self, titles: list[str]):
        """Test that invalid titles raise DemistoException."""
        with pytest.raises(Exception, match="Invalid event type"):
            get_log_types_from_titles(titles)


# endregion

# region extract_time_from_event tests


class TestExtractTimeFromEvent:
    """Tests for the extract_time_from_event helper function."""

    def test_alert_event_with_epoch_ms(self):
        """Test extracting time from an alert event with epoch ms timestamp."""
        event = {"finding_info": {"created_time": 1704067200000}}
        result = extract_time_from_event(event, LogType.ALERTS)
        assert result == "2024-01-01T00:00:00Z"

    def test_audit_event_with_iso_string(self):
        """Test extracting time from an audit event with ISO 8601 string."""
        event = {"created_at": "2024-01-01T00:00:00Z"}
        result = extract_time_from_event(event, LogType.AUDIT)
        assert result == "2024-01-01T00:00:00Z"

    @pytest.mark.parametrize(
        "event, log_type_name",
        [
            ({}, "ALERTS"),
            ({"finding_info": {}}, "ALERTS"),
            ({}, "AUDIT"),
        ],
        ids=["empty_alert", "missing_created_time", "empty_audit"],
    )
    def test_missing_time_field(self, event: dict, log_type_name: str):
        """Test extracting time when the field is missing returns None."""
        log_type = LogType[log_type_name]
        result = extract_time_from_event(event, log_type)
        assert result is None


# endregion

# region add_time_to_events tests


class TestAddTimeToEvents:
    """Tests for the add_time_to_events helper function."""

    @pytest.mark.parametrize(
        "events, log_type_name, expected_time, expected_source",
        [
            (
                [{"id": "alert-001", "finding_info": {"created_time": 1704067200000}}],
                "ALERTS",
                "2024-01-01T00:00:00Z",
                "Alerts",
            ),
            (
                [{"id": "audit-001", "created_at": "2024-01-01T00:00:00Z"}],
                "AUDIT",
                "2024-01-01T00:00:00Z",
                "Audit",
            ),
        ],
        ids=["alert_events", "audit_events"],
    )
    def test_events_with_time(self, events, log_type_name, expected_time, expected_source):
        """Test enriching events with _time and source_log_type."""
        log_type = LogType[log_type_name]
        add_time_to_events(events, log_type)

        assert events[0]["_time"] == expected_time
        assert events[0]["source_log_type"] == expected_source

    def test_missing_time_field(self):
        """Test enriching events when time field is missing still sets source_log_type."""
        events = [{"id": "audit-001"}]
        add_time_to_events(events, LogType.AUDIT)

        assert "_time" not in events[0]
        assert events[0]["source_log_type"] == "Audit"


# endregion

# region get_event_id tests


class TestGetEventId:
    """Tests for the get_event_id helper function."""

    @pytest.mark.parametrize(
        "event, expected_id",
        [
            ({"id": "123"}, "123"),
            ({"alert_id": "456"}, "456"),
            ({"log_id": "789"}, "789"),
            ({"uuid": "abc"}, "abc"),
            ({"id": 42}, "42"),
        ],
        ids=["id_field", "alert_id_field", "log_id_field", "uuid_field", "numeric_id"],
    )
    def test_valid_id_fields(self, event: dict, expected_id: str):
        """Test extracting event ID from various field names."""
        assert get_event_id(event) == expected_id

    def test_no_id_field(self):
        """Test that missing ID field returns None."""
        assert get_event_id({}) is None
        assert get_event_id({"name": "test"}) is None


# endregion

# region deduplicate_events tests


class TestDeduplicateEvents:
    """Tests for the deduplicate_events helper function."""

    @pytest.mark.parametrize(
        "events, last_ids, expected_count",
        [
            (
                [{"id": "1"}, {"id": "2"}],
                [],
                2,
            ),
            (
                [{"id": "1"}, {"id": "2"}, {"id": "3"}],
                ["1"],
                2,
            ),
            (
                [{"id": "1"}, {"id": "2"}],
                ["1", "2"],
                0,
            ),
            (
                [],
                ["1"],
                0,
            ),
        ],
        ids=["no_previous_ids", "with_duplicates", "all_duplicates", "empty_events"],
    )
    def test_deduplication(self, events: list, last_ids: list, expected_count: int):
        """Test deduplication with various scenarios."""
        result = deduplicate_events(events, last_fetched_ids=last_ids)
        assert len(result) == expected_count

    def test_no_duplicates_found(self):
        """Test dedup when none of the events match previous IDs (covers line 237)."""
        events = [{"id": "3"}, {"id": "4"}]
        result = deduplicate_events(events, last_fetched_ids=["1", "2"])
        assert len(result) == 2


# endregion

# region Client tests


class TestClient:
    """Tests for the Client class methods."""

    def test_get_events_page_alerts(self, mock_client, alerts_response, mocker):
        """Test fetching a page of alerts."""
        mocker.patch.object(mock_client, "_http_request", return_value=alerts_response)

        events = mock_client.get_events_page(
            log_type=LogType.ALERTS,
            created_at_gte="2024-01-01T00:00:00Z",
            page=1,
            page_size=100,
        )

        assert len(events) == 2
        assert events[0]["id"] == "alert-001"

    def test_get_events_page_audit(self, mock_client, audit_response, mocker):
        """Test fetching a page of audit logs."""
        mocker.patch.object(mock_client, "_http_request", return_value=audit_response)

        events = mock_client.get_events_page(
            log_type=LogType.AUDIT,
            created_at_gte="2024-01-01T00:00:00Z",
            page=1,
            page_size=100,
            audit_types=["policies", "settings"],
        )

        assert len(events) == 2
        assert events[0]["id"] == "audit-001"

    def test_get_events_page_with_created_at_lte(self, mock_client, alerts_response, mocker):
        """Test fetching events with created_at_lte parameter (covers line 322)."""
        mocker.patch.object(mock_client, "_http_request", return_value=alerts_response)

        events = mock_client.get_events_page(
            log_type=LogType.ALERTS,
            created_at_gte="2024-01-01T00:00:00Z",
            created_at_lte="2024-01-02T00:00:00Z",
            page=1,
            page_size=100,
        )

        assert len(events) == 2
        # Verify created_at_lte was passed in params
        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["params"]["created_at_lte"] == "2024-01-02T00:00:00Z"

    def test_sort_direction_asc_alerts(self, mock_client, alerts_response, mocker):
        """Test that sort_direction=asc is passed to the API for alerts."""
        mocker.patch.object(mock_client, "_http_request", return_value=alerts_response)

        mock_client.get_events_page(
            log_type=LogType.ALERTS,
            created_at_gte="2024-01-01T00:00:00Z",
            page=1,
            page_size=100,
        )

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["params"]["sort_direction"] == Config.SORT_DIRECTION

    def test_sort_direction_asc_audit(self, mock_client, audit_response, mocker):
        """Test that sort_direction=asc is passed to the API for audit logs."""
        mocker.patch.object(mock_client, "_http_request", return_value=audit_response)

        mock_client.get_events_page(
            log_type=LogType.AUDIT,
            created_at_gte="2024-01-01T00:00:00Z",
            page=1,
            page_size=100,
        )

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["params"]["sort_direction"] == Config.SORT_DIRECTION

    def test_get_events_page_empty(self, mock_client, empty_response, mocker):
        """Test fetching when no events are returned."""
        mocker.patch.object(mock_client, "_http_request", return_value=empty_response)

        events = mock_client.get_events_page(
            log_type=LogType.ALERTS,
            page=1,
            page_size=100,
        )

        assert len(events) == 0


# endregion

# region fetch_events_with_pagination tests


class TestFetchEventsWithPagination:
    """Tests for the fetch_events_with_pagination function."""

    def test_single_page(self, mock_client, alerts_response, mocker):
        """Test fetching events that fit in a single page."""
        mocker.patch.object(mock_client, "get_events_page", return_value=alerts_response["alerts"])

        events = fetch_events_with_pagination(
            mock_client,
            log_type=LogType.ALERTS,
            created_after="2024-01-01T00:00:00Z",
            max_events=100,
        )

        assert len(events) == 2

    def test_multiple_pages(self, mock_client, mocker):
        """Test fetching events across multiple pages."""
        # page_size = min(MAX_PAGE_SIZE, max_events) = min(500, 1000) = 500
        # page1 must have exactly page_size items to trigger next page fetch
        page_size = Config.MAX_PAGE_SIZE
        page1 = [{"id": f"event-{i}", "created_at": f"2024-01-01T00:{i:02d}:00Z"} for i in range(page_size)]
        page2 = [{"id": f"event-{i}", "created_at": f"2024-01-01T01:{i:02d}:00Z"} for i in range(3)]

        mocker.patch.object(mock_client, "get_events_page", side_effect=[page1, page2])

        events = fetch_events_with_pagination(
            mock_client,
            log_type=LogType.AUDIT,
            created_after="2024-01-01T00:00:00Z",
            max_events=1000,
        )

        assert len(events) == page_size + 3

    def test_empty_response(self, mock_client, mocker):
        """Test fetching when API returns no events."""
        mocker.patch.object(mock_client, "get_events_page", return_value=[])

        events = fetch_events_with_pagination(
            mock_client,
            log_type=LogType.ALERTS,
            created_after="2024-01-01T00:00:00Z",
            max_events=100,
        )

        assert len(events) == 0

    def test_max_events_limit(self, mock_client, mocker):
        """Test that max_events limit is respected."""
        large_page = [{"id": f"event-{i}", "created_at": f"2024-01-01T00:00:{i:02d}Z"} for i in range(500)]
        mocker.patch.object(mock_client, "get_events_page", return_value=large_page)

        events = fetch_events_with_pagination(
            mock_client,
            log_type=LogType.AUDIT,
            created_after="2024-01-01T00:00:00Z",
            max_events=10,
        )

        assert len(events) == 10

    def test_max_pages_limit(self, mock_client, mocker):
        """Test pagination stops at MAX_PAGES_PER_FETCH (covers lines 438-439)."""
        page_size = Config.MAX_PAGE_SIZE
        # Return full pages every time to force pagination to continue
        full_page = [{"id": f"event-{i}", "created_at": f"2024-01-01T00:00:{i:02d}Z"} for i in range(page_size)]
        mocker.patch.object(mock_client, "get_events_page", return_value=full_page)

        fetch_events_with_pagination(
            mock_client,
            log_type=LogType.AUDIT,
            created_after="2024-01-01T00:00:00Z",
            max_events=999999,  # Very high limit so pages limit is hit first
        )

        # Should have fetched MAX_PAGES_PER_FETCH pages
        assert mock_client.get_events_page.call_count == Config.MAX_PAGES_PER_FETCH


# endregion

# region Command tests


class TestTestModule:
    """Tests for the test_module command."""

    def test_success(self, mock_client, mocker):
        """Test successful test-module."""
        mocker.patch.object(mock_client, "get_events_page", return_value=[{"id": "1"}])

        result = koi_test_module(mock_client)
        assert result == "ok"

    def test_auth_failure(self, mock_client, mocker):
        """Test test-module with authentication failure."""
        mocker.patch.object(mock_client, "get_events_page", side_effect=Exception("401 Unauthorized"))

        result = koi_test_module(mock_client)
        assert "Authorization Error" in result

    def test_non_auth_failure_reraises(self, mock_client, mocker):
        """Test test-module re-raises non-auth errors (covers line 378)."""
        mocker.patch.object(mock_client, "get_events_page", side_effect=Exception("Connection timeout"))

        with pytest.raises(Exception, match="Connection timeout"):
            koi_test_module(mock_client)


@pytest.fixture()
def mock_xsiam(mocker):
    """Mock is_xsiam to return True so resolve_should_push_events allows pushing.

    Patches is_xsiam in the CommonServerPython namespace, since
    resolve_should_push_events (defined there) resolves is_xsiam from its own module.
    """
    mocker.patch("CommonServerPython.is_xsiam", return_value=True)


class TestGetEventsCommand:
    """Tests for the koi-get-events command."""

    @pytest.fixture(autouse=True)
    def _mock_xsiam(self, mock_xsiam):
        """Apply the shared mock_xsiam fixture to all tests in this class."""
        return mock_xsiam

    def test_get_events_alerts_and_audit(self, mock_client, alerts_response, audit_response, mocker):
        """Test get-events command fetching both alerts and audit logs."""
        mocker.patch.object(
            mock_client,
            "get_events_page",
            side_effect=[alerts_response["alerts"], audit_response["data"]],
        )

        args = {"limit": "50", "should_push_events": "false"}
        params = {"event_types_to_fetch": "Alerts,Audit"}

        result = get_events_command(mock_client, args, params)

        assert not isinstance(result, str)
        assert "KOI Events" in result.readable_output  # type: ignore[union-attr]

    def test_get_events_push_to_xsiam(self, mock_client, alerts_response, mocker):
        """Test get-events command with push to XSIAM."""
        mocker.patch.object(mock_client, "get_events_page", return_value=alerts_response["alerts"])
        mock_send = mocker.patch.object(mock_client, "send_events")

        args = {"limit": "50", "should_push_events": "true", "event_type": "Alerts"}
        params = {"event_types_to_fetch": "Alerts"}

        result = get_events_command(mock_client, args, params)

        assert isinstance(result, str)
        assert "Successfully retrieved and pushed" in result
        mock_send.assert_called_once()


class TestFetchEventsCommand:
    """Tests for the fetch-events command."""

    def test_first_run(self, mock_client, alerts_response, audit_response, mocker):
        """Test fetch-events on first run (no last_run state)."""

        def side_effect_get_events_page(**kwargs):
            log_type = kwargs.get("log_type")
            if log_type == LogType.ALERTS:
                return alerts_response["alerts"]
            return audit_response["data"]

        mocker.patch.object(mock_client, "get_events_page", side_effect=side_effect_get_events_page)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts,Audit",
            },
        )
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mock_send = mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        mock_send.assert_called_once()
        mock_set_last_run.assert_called_once()

        # Verify last_run contains state for both log types
        last_run_arg = mock_set_last_run.call_args[0][0]
        assert "last_fetch_alerts" in last_run_arg
        assert "last_fetch_audit" in last_run_arg

    def test_subsequent_run_with_dedup(self, mock_client, alerts_response, mocker):
        """Test fetch-events on subsequent run with deduplication."""
        mocker.patch.object(mock_client, "get_events_page", return_value=alerts_response["alerts"])
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts",
            },
        )
        mocker.patch.object(
            demisto,
            "getLastRun",
            return_value={
                "last_fetch_alerts": "2024-01-01T00:00:00Z",
                "previous_ids_alerts": ["alert-001"],
            },
        )
        mock_send = mocker.patch.object(mock_client, "send_events")
        mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        # Should have sent only 1 event (alert-002, since alert-001 is deduped)
        mock_send.assert_called_once()
        sent_events = mock_send.call_args[0][0]
        assert len(sent_events) == 1
        assert sent_events[0]["id"] == "alert-002"

    def test_no_events(self, mock_client, mocker):
        """Test fetch-events when no events are returned."""
        mocker.patch.object(mock_client, "get_events_page", return_value=[])
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts",
            },
        )
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mock_send = mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        mock_send.assert_not_called()
        mock_set_last_run.assert_called_once()

    def test_all_events_are_duplicates(self, mock_client, alerts_response, mocker):
        """Test fetch-events when all returned events are duplicates (covers line 578)."""
        mocker.patch.object(mock_client, "get_events_page", return_value=alerts_response["alerts"])
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts",
            },
        )
        mocker.patch.object(
            demisto,
            "getLastRun",
            return_value={
                "last_fetch_alerts": "2024-01-01T00:00:00Z",
                "previous_ids_alerts": ["alert-001", "alert-002"],
            },
        )
        mock_send = mocker.patch.object(mock_client, "send_events")
        mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        mock_send.assert_not_called()

    def test_hwm_timestamp_unchanged_merges_ids(self, mock_client, mocker):
        """Test that when HWM timestamp hasn't changed, IDs are merged (covers line 594)."""
        # Events with same timestamp as last_fetch
        events = [
            {"id": "alert-003", "finding_info": {"created_time": 1704067200000}},
        ]
        mocker.patch.object(mock_client, "get_events_page", return_value=events)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts",
            },
        )
        mocker.patch.object(
            demisto,
            "getLastRun",
            return_value={
                "last_fetch_alerts": "2024-01-01T00:00:00Z",
                "previous_ids_alerts": ["alert-001"],
            },
        )
        mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        # Verify IDs were merged
        last_run_arg = mock_set_last_run.call_args[0][0]
        previous_ids = last_run_arg["previous_ids_alerts"]
        assert "alert-001" in previous_ids
        assert "alert-003" in previous_ids

    def test_last_event_missing_time(self, mock_client, mocker):
        """Test fetch-events when last event has no time field (covers line 600)."""
        # Audit event without created_at
        events = [{"id": "audit-no-time"}]
        mocker.patch.object(mock_client, "get_events_page", return_value=events)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Audit",
            },
        )
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        # Event should still be sent (it's new), but last_run should not have audit timestamp
        last_run_arg = mock_set_last_run.call_args[0][0]
        assert "last_fetch_audit" not in last_run_arg

    def test_alerts_failure_does_not_block_audit(self, mock_client, audit_response, mocker):
        """Test that if alerts fetching fails, audit logs are still fetched and sent."""

        # Alerts raises an exception, audit returns data
        def side_effect_get_events_page(**kwargs):
            if kwargs.get("log_type") == LogType.ALERTS:
                raise Exception("API timeout for alerts")
            return audit_response["data"]

        mocker.patch.object(mock_client, "get_events_page", side_effect=side_effect_get_events_page)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts,Audit",
            },
        )
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mock_send = mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        # Audit events should still be sent despite alerts failure
        mock_send.assert_called_once()
        sent_events = mock_send.call_args[0][0]
        assert len(sent_events) == 2
        assert all(e.get("source_log_type") == "Audit" for e in sent_events)

        # Last run should have audit state but no alerts state
        last_run_arg = mock_set_last_run.call_args[0][0]
        assert "last_fetch_audit" in last_run_arg
        assert "last_fetch_alerts" not in last_run_arg

    def test_audit_failure_does_not_block_alerts(self, mock_client, alerts_response, mocker):
        """Test that if audit fetching fails, alerts are still fetched and sent."""

        # Alerts returns data, audit raises an exception
        def side_effect_get_events_page(**kwargs):
            if kwargs.get("log_type") == LogType.AUDIT:
                raise Exception("API timeout for audit")
            return alerts_response["alerts"]

        mocker.patch.object(mock_client, "get_events_page", side_effect=side_effect_get_events_page)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts,Audit",
            },
        )
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mock_send = mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        # Alerts events should still be sent despite audit failure
        mock_send.assert_called_once()
        sent_events = mock_send.call_args[0][0]
        assert len(sent_events) == 2
        assert all(e.get("source_log_type") == "Alerts" for e in sent_events)

        # Last run should have alerts state but no audit state
        last_run_arg = mock_set_last_run.call_args[0][0]
        assert "last_fetch_alerts" in last_run_arg
        assert "last_fetch_audit" not in last_run_arg


class TestLastRunState:
    """Parametrized tests for last_run state management across all scenarios."""

    @pytest.mark.parametrize(
        "description, event_types, initial_last_run, alert_events, audit_events, "
        "expected_last_run_keys, expected_missing_keys, expected_event_count",
        [
            (
                "first_run_both_types",
                "Alerts,Audit",
                {},
                [{"id": "a1", "finding_info": {"created_time": 1704067200000}}],
                [{"id": "au1", "created_at": "2024-01-01T00:00:00Z"}],
                ["last_fetch_alerts", "previous_ids_alerts", "last_fetch_audit", "previous_ids_audit"],
                [],
                2,
            ),
            (
                "first_run_alerts_only",
                "Alerts",
                {},
                [{"id": "a1", "finding_info": {"created_time": 1704067200000}}],
                [],
                ["last_fetch_alerts", "previous_ids_alerts"],
                ["last_fetch_audit"],
                1,
            ),
            (
                "first_run_audit_only",
                "Audit",
                {},
                [],
                [{"id": "au1", "created_at": "2024-01-01T00:00:00Z"}],
                ["last_fetch_audit", "previous_ids_audit"],
                ["last_fetch_alerts"],
                1,
            ),
            (
                "subsequent_run_preserves_existing_state",
                "Alerts",
                {
                    "last_fetch_alerts": "2024-01-01T00:00:00Z",
                    "previous_ids_alerts": ["old-id"],
                    "last_fetch_audit": "2024-01-01T00:00:00Z",
                    "previous_ids_audit": ["old-audit-id"],
                },
                [{"id": "a2", "finding_info": {"created_time": 1704067260000}}],
                [],
                ["last_fetch_alerts", "previous_ids_alerts", "last_fetch_audit", "previous_ids_audit"],
                [],
                1,
            ),
            (
                "no_events_preserves_state",
                "Alerts,Audit",
                {
                    "last_fetch_alerts": "2024-01-01T00:00:00Z",
                    "previous_ids_alerts": ["existing-id"],
                },
                [],
                [],
                ["last_fetch_alerts", "previous_ids_alerts"],
                [],
                0,
            ),
            (
                "hwm_unchanged_merges_ids",
                "Alerts",
                {
                    "last_fetch_alerts": "2024-01-01T00:00:00Z",
                    "previous_ids_alerts": ["a1"],
                },
                [
                    {"id": "a1", "finding_info": {"created_time": 1704067200000}},
                    {"id": "a2", "finding_info": {"created_time": 1704067200000}},
                ],
                [],
                ["last_fetch_alerts", "previous_ids_alerts"],
                [],
                1,
            ),
        ],
        ids=[
            "first_run_both_types",
            "first_run_alerts_only",
            "first_run_audit_only",
            "subsequent_run_preserves_existing_state",
            "no_events_preserves_state",
            "hwm_unchanged_merges_ids",
        ],
    )
    def test_last_run_state(
        self,
        mock_client,
        mocker,
        description: str,
        event_types: str,
        initial_last_run: dict,
        alert_events: list,
        audit_events: list,
        expected_last_run_keys: list,
        expected_missing_keys: list,
        expected_event_count: int,
    ):
        """Parametrized test for last_run state management across all scenarios."""

        def side_effect_get_events_page(**kwargs):
            log_type = kwargs.get("log_type")
            if log_type == LogType.ALERTS:
                return alert_events
            return audit_events

        mocker.patch.object(mock_client, "get_events_page", side_effect=side_effect_get_events_page)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": event_types,
            },
        )
        mocker.patch.object(demisto, "getLastRun", return_value=initial_last_run)
        mock_send = mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        # Verify setLastRun was called exactly once (single write, no race condition)
        mock_set_last_run.assert_called_once()
        last_run_arg = mock_set_last_run.call_args[0][0]

        # Verify expected keys are present
        for key in expected_last_run_keys:
            assert key in last_run_arg, f"Expected key '{key}' missing from last_run: {last_run_arg}"

        # Verify expected missing keys are absent
        for key in expected_missing_keys:
            assert key not in last_run_arg, f"Unexpected key '{key}' found in last_run: {last_run_arg}"

        # Verify event count
        if expected_event_count > 0:
            mock_send.assert_called_once()
            assert len(mock_send.call_args[0][0]) == expected_event_count
        else:
            mock_send.assert_not_called()

    def test_last_run_ids_stored_per_type(self, mock_client, mocker):
        """Test that IDs are stored independently per event type in last_run."""

        def side_effect_get_events_page(**kwargs):
            log_type = kwargs.get("log_type")
            if log_type == LogType.ALERTS:
                return [{"id": "alert-100", "finding_info": {"created_time": 1704067200000}}]
            return [{"id": "audit-200", "created_at": "2024-01-01T00:00:00Z"}]

        mocker.patch.object(mock_client, "get_events_page", side_effect=side_effect_get_events_page)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts,Audit",
            },
        )
        mocker.patch.object(demisto, "getLastRun", return_value={})
        mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        last_run_arg = mock_set_last_run.call_args[0][0]

        # Verify IDs are stored per type, not mixed
        assert "alert-100" in last_run_arg["previous_ids_alerts"]
        assert "audit-200" in last_run_arg["previous_ids_audit"]
        assert "audit-200" not in last_run_arg["previous_ids_alerts"]
        assert "alert-100" not in last_run_arg["previous_ids_audit"]

    def test_last_run_single_get_single_set(self, mock_client, alerts_response, audit_response, mocker):
        """Test that getLastRun is called once and setLastRun is called once (no race condition)."""

        def side_effect_get_events_page(**kwargs):
            log_type = kwargs.get("log_type")
            if log_type == LogType.ALERTS:
                return alerts_response["alerts"]
            return audit_response["data"]

        mocker.patch.object(mock_client, "get_events_page", side_effect=side_effect_get_events_page)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts,Audit",
            },
        )
        mock_get_last_run = mocker.patch.object(demisto, "getLastRun", return_value={})
        mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        # Single read, single write — no race condition
        mock_get_last_run.assert_called_once()
        mock_set_last_run.assert_called_once()

    def test_last_run_failure_preserves_successful_type_state(self, mock_client, mocker):
        """Test that when one type fails, the other type's state is still saved in last_run."""

        def side_effect_get_events_page(**kwargs):
            log_type = kwargs.get("log_type")
            if log_type == LogType.ALERTS:
                return [{"id": "alert-ok", "finding_info": {"created_time": 1704067200000}}]
            raise Exception("Audit API is down")

        mocker.patch.object(mock_client, "get_events_page", side_effect=side_effect_get_events_page)
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "max_fetch": "5000",
                "event_types_to_fetch": "Alerts,Audit",
            },
        )
        mocker.patch.object(
            demisto,
            "getLastRun",
            return_value={
                "last_fetch_audit": "2024-01-01T00:00:00Z",
                "previous_ids_audit": ["old-audit-id"],
            },
        )
        mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        fetch_events_command(mock_client)

        last_run_arg = mock_set_last_run.call_args[0][0]

        # Alerts state should be updated (successful)
        assert "last_fetch_alerts" in last_run_arg
        assert "alert-ok" in last_run_arg["previous_ids_alerts"]

        # Audit state should be preserved from initial last_run (failed, not overwritten)
        assert last_run_arg["last_fetch_audit"] == "2024-01-01T00:00:00Z"
        assert last_run_arg["previous_ids_audit"] == ["old-audit-id"]


# endregion

# region Date helper tests


class TestParseDate:
    """Tests for parse_date_or_use_current and get_formatted_utc_time."""

    @pytest.mark.parametrize(
        "date_input, expected_contains",
        [
            ("2024-01-01T00:00:00Z", "2024-01-01"),
            ("2024-06-15T12:30:00Z", "2024-06-15"),
        ],
        ids=["iso_format", "iso_with_time"],
    )
    def test_get_formatted_utc_time_valid(self, date_input: str, expected_contains: str):
        """Test formatting valid date strings."""
        result = get_formatted_utc_time(date_input)
        assert expected_contains in result

    def test_get_formatted_utc_time_none_returns_current(self):
        """Test that None input returns current UTC time."""
        result = get_formatted_utc_time(None)
        assert result  # Should return a non-empty string

    @pytest.mark.parametrize(
        "date_input",
        [
            None,
            "",
        ],
        ids=["none_input", "empty_string"],
    )
    def test_parse_date_or_use_current_fallback(self, date_input):
        """Test that empty/None input falls back to current UTC."""
        result = parse_date_or_use_current(date_input)
        assert isinstance(result, datetime)

    def test_parse_date_or_use_current_valid_iso(self):
        """Test parsing a valid ISO 8601 date string."""
        result = parse_date_or_use_current("2024-01-01T00:00:00Z")
        assert isinstance(result, datetime)
        assert result.year == 2024
        assert result.month == 1
        assert result.day == 1

    def test_parse_date_or_use_current_unparseable(self, mocker):
        """Test fallback when arg_to_datetime returns None (covers lines 115-116)."""
        mocker.patch("Koi.arg_to_datetime", return_value=None)

        result = parse_date_or_use_current("completely-invalid-date")
        assert isinstance(result, datetime)


# endregion

# region get_events_command error tests


class TestGetEventsCommandErrors:
    """Tests for error handling in get_events_command."""

    @pytest.fixture(autouse=True)
    def _mock_xsiam(self, mock_xsiam):
        """Apply the shared mock_xsiam fixture to all tests in this class."""

    def test_should_push_events_overridden_on_non_xsiam(self, mock_client, alerts_response, mocker):
        """Test that should_push_events is silently overridden to False on non-XSIAM platforms via resolve_should_push_events."""
        mocker.patch("CommonServerPython.is_xsiam", return_value=False)
        mocker.patch.object(mock_client, "get_events_page", return_value=alerts_response["alerts"])
        mock_send = mocker.patch.object(mock_client, "send_events")

        args = {"limit": "10", "should_push_events": "true", "event_type": "Alerts"}
        params = {"event_types_to_fetch": "Alerts"}

        result = get_events_command(mock_client, args, params)

        # Events should NOT be pushed (send_events should not be called)
        mock_send.assert_not_called()
        # Events should be returned as CommandResults instead
        assert isinstance(result, CommandResults)
        assert "KOI Events" in result.readable_output

    def test_invalid_event_type(self, mock_client):
        """Test get-events command with invalid event type raises error."""
        args = {"event_type": "InvalidType", "limit": "10", "should_push_events": "false"}
        params = {"event_types_to_fetch": "Alerts"}

        with pytest.raises(Exception, match="Invalid event type"):
            get_events_command(mock_client, args, params)


# endregion

# region Config and constants tests


class TestConfig:
    """Tests for configuration constants."""

    def test_valid_audit_types(self):
        """Test that VALID_AUDIT_TYPES contains all expected types."""
        expected = [
            "approval_requests",
            "devices",
            "endpoints",
            "extensions",
            "firewall",
            "guardrails",
            "notifications",
            "policies",
            "remediation",
            "requests",
            "settings",
            "vetting",
        ]
        assert expected == VALID_AUDIT_TYPES

    def test_config_values(self):
        """Test that Config class has expected default values."""
        assert Config.VENDOR == "koi"
        assert Config.PRODUCT == "koi"
        assert Config.MAX_PAGE_SIZE == 500
        assert Config.DEFAULT_MAX_FETCH == 5000
        assert Config.MAX_PAGES_PER_FETCH == 10
        assert Config.DEFAULT_FROM_TIME == "5 minutes ago"


# endregion

# region Main tests


class TestMain:
    """Tests for the main entry point."""

    def test_main_test_module(self, mocker):
        """Test main routes test-module command correctly."""
        mocker.patch.object(demisto, "command", return_value="test-module")
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mocker.patch("Koi.test_module", return_value="ok")

        main()

        mock_return.assert_called_once_with("ok")

    def test_main_unknown_command(self, mocker):
        """Test main raises error for unknown command."""
        mocker.patch.object(demisto, "command", return_value="unknown-command")
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
            },
        )
        mocker.patch.object(demisto, "error")
        mock_return_error = mocker.patch("Koi.return_error")

        main()

        mock_return_error.assert_called_once()
        assert "not implemented" in mock_return_error.call_args[0][0]

    def test_main_fetch_events(self, mocker):
        """Test main routes fetch-events command correctly (covers lines 664-665)."""
        mocker.patch.object(demisto, "command", return_value="fetch-events")
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_fetch = mocker.MagicMock()
        COMMAND_MAP["fetch-events"] = mock_fetch

        main()

        mock_fetch.assert_called_once()

    def test_main_get_events(self, mocker):
        """Test main routes koi-get-events command correctly (covers lines 667-668)."""
        mocker.patch.object(demisto, "command", return_value="koi-get-events")
        mocker.patch.object(demisto, "args", return_value={"limit": "10", "should_push_events": "false"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
                "event_types_to_fetch": "Alerts",
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_get_events = mocker.MagicMock(return_value="mock_result")
        COMMAND_MAP["koi-get-events"] = mock_get_events

        main()

        mock_return.assert_called_once_with("mock_result")

    def test_main_invalid_audit_types(self, mocker):
        """Test main raises error for invalid audit types filter (covers lines 648-650)."""
        mocker.patch.object(demisto, "command", return_value="test-module")
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
                "audit_types_filter": "invalid_type",
            },
        )
        mocker.patch.object(demisto, "error")
        mock_return_error = mocker.patch("Koi.return_error")

        main()

        mock_return_error.assert_called_once()
        assert "Invalid audit log type" in mock_return_error.call_args[0][0]

    def test_main_routes_policy_list(self, mocker):
        """Test main routes koi-policy-list command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-policy-list")
        mocker.patch.object(demisto, "args", return_value={"page": "1", "limit": "10"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_policy_list = mocker.MagicMock(return_value="mock_policy_result")
        COMMAND_MAP["koi-policy-list"] = mock_policy_list

        main()

        mock_return.assert_called_once_with("mock_policy_result")

    def test_main_routes_allowlist_get(self, mocker):
        """Test main routes koi-allowlist-get command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-allowlist-get")
        mocker.patch.object(demisto, "args", return_value={})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_allowlist_get = mocker.MagicMock(return_value="mock_allowlist_result")
        COMMAND_MAP["koi-allowlist-get"] = mock_allowlist_get

        main()

        mock_return.assert_called_once_with("mock_allowlist_result")

    def test_main_routes_allowlist_item_remove(self, mocker):
        """Test main routes koi-allowlist-items-remove command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-allowlist-items-remove")
        mocker.patch.object(demisto, "args", return_value={"item_id": "ext-123", "marketplace": "vscode"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_allowlist_remove = mocker.MagicMock(return_value="mock_allowlist_remove_result")
        COMMAND_MAP["koi-allowlist-items-remove"] = mock_allowlist_remove

        main()

        mock_return.assert_called_once_with("mock_allowlist_remove_result")

    def test_main_routes_allowlist_item_add(self, mocker):
        """Test main routes koi-allowlist-items-add command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-allowlist-items-add")
        mocker.patch.object(demisto, "args", return_value={"item_id": "ext-123", "marketplace": "vscode"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_allowlist_add = mocker.MagicMock(return_value="mock_allowlist_add_result")
        COMMAND_MAP["koi-allowlist-items-add"] = mock_allowlist_add

        main()

        mock_return.assert_called_once_with("mock_allowlist_add_result")

    def test_main_routes_blocklist_get(self, mocker):
        """Test main routes koi-blocklist-get command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-blocklist-get")
        mocker.patch.object(demisto, "args", return_value={})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_blocklist_get = mocker.MagicMock(return_value="mock_blocklist_result")
        COMMAND_MAP["koi-blocklist-get"] = mock_blocklist_get

        main()

        mock_return.assert_called_once_with("mock_blocklist_result")

    def test_main_routes_blocklist_item_remove(self, mocker):
        """Test main routes koi-blocklist-items-remove command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-blocklist-items-remove")
        mocker.patch.object(demisto, "args", return_value={"item_id": "mal-001", "marketplace": "vscode"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_blocklist_remove = mocker.MagicMock(return_value="mock_blocklist_remove_result")
        COMMAND_MAP["koi-blocklist-items-remove"] = mock_blocklist_remove

        main()

        mock_return.assert_called_once_with("mock_blocklist_remove_result")

    def test_main_routes_blocklist_item_add(self, mocker):
        """Test main routes koi-blocklist-items-add command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-blocklist-items-add")
        mocker.patch.object(demisto, "args", return_value={"item_id": "mal-001", "marketplace": "vscode"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_blocklist_add = mocker.MagicMock(return_value="mock_blocklist_add_result")
        COMMAND_MAP["koi-blocklist-items-add"] = mock_blocklist_add

        main()

        mock_return.assert_called_once_with("mock_blocklist_add_result")

    def test_main_routes_policy_status_update(self, mocker):
        """Test main routes koi-policy-status-update command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-policy-status-update")
        mocker.patch.object(demisto, "args", return_value={"policy_id": "1", "enabled": "true"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_policy_update = mocker.MagicMock(return_value="mock_policy_update_result")
        COMMAND_MAP["koi-policy-status-update"] = mock_policy_update

        main()

        mock_return.assert_called_once_with("mock_policy_update_result")

    def test_main_routes_inventory_list(self, mocker):
        """Test main routes koi-inventory-list command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-inventory-list")
        mocker.patch.object(demisto, "args", return_value={"page": "1"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_inventory_list = mocker.MagicMock(return_value="mock_inventory_result")
        COMMAND_MAP["koi-inventory-list"] = mock_inventory_list

        main()

        mock_return.assert_called_once_with("mock_inventory_result")

    def test_main_routes_inventory_item_get(self, mocker):
        """Test main routes koi-inventory-item-get command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-inventory-item-get")
        mocker.patch.object(demisto, "args", return_value={"item_id": "abc123", "marketplace": "chrome_web_store"})
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_inventory_item_get = mocker.MagicMock(return_value="mock_inventory_item_result")
        COMMAND_MAP["koi-inventory-item-get"] = mock_inventory_item_get

        main()

        mock_return.assert_called_once_with("mock_inventory_item_result")

    def test_main_routes_inventory_search(self, mocker):
        """Test main routes koi-inventory-search command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-inventory-search")
        mocker.patch.object(
            demisto,
            "args",
            return_value={"filter_json": '{"field": "risk_level", "operator": "eq", "value": "high"}'},
        )
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_inventory_search = mocker.MagicMock(return_value="mock_inventory_search_result")
        COMMAND_MAP["koi-inventory-search"] = mock_inventory_search

        main()

        mock_return.assert_called_once_with("mock_inventory_search_result")

    def test_main_routes_inventory_item_endpoints_list(self, mocker):
        """Test main routes koi-inventory-item-endpoints-list command correctly."""
        mocker.patch.object(demisto, "command", return_value="koi-inventory-item-endpoints-list")
        mocker.patch.object(
            demisto,
            "args",
            return_value={"item_id": "abc123", "marketplace": "chrome_web_store"},
        )
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "url": "https://api.prod.koi.security/",
                "api_key": {"password": "test-key"},
                "insecure": False,
                "proxy": False,
            },
        )
        mocker.patch("Koi.Client")
        mock_return = mocker.patch("Koi.return_results")
        mock_endpoints_list = mocker.MagicMock(return_value="mock_endpoints_result")
        COMMAND_MAP["koi-inventory-item-endpoints-list"] = mock_endpoints_list

        main()

        mock_return.assert_called_once_with("mock_endpoints_result")


# endregion

# region Policy command tests


class TestKoiPolicyListCommand:
    """Tests for the koi-policy-list command."""

    def test_policy_list_single_page_mode(self, mock_client, policies_response, mocker):
        """Test koi-policy-list in single-page mode (page arg provided)."""
        mocker.patch.object(mock_client, "get_policies", return_value=policies_response)

        args = {"page": "1"}
        result = koi_policy_list_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Policy"
        assert result.outputs_key_field == "id"
        assert len(result.outputs) == 2
        assert result.outputs[0]["id"] == 1
        assert result.outputs[0]["name"] == "My Policy"
        assert result.outputs[1]["id"] == 2

        # Single-page mode: called with page and default page_size
        mock_client.get_policies.assert_called_once_with(page=1, page_size=Config.DEFAULT_PAGE_SIZE)

    def test_policy_list_single_page_custom_page_size(self, mock_client, policies_response, mocker):
        """Test koi-policy-list in single-page mode with custom page_size."""
        mocker.patch.object(mock_client, "get_policies", return_value=policies_response)

        args = {"page": "2", "page_size": "50"}
        result = koi_policy_list_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Policy"
        mock_client.get_policies.assert_called_once_with(page=2, page_size=50)

    def test_policy_list_single_page_ignores_limit(self, mock_client, policies_response, mocker):
        """Test that when page is provided, limit is ignored."""
        mocker.patch.object(mock_client, "get_policies", return_value=policies_response)

        args = {"page": "3", "page_size": "25", "limit": "200"}
        result = koi_policy_list_command(mock_client, args)

        # Should use single-page mode, not auto-paginate
        mock_client.get_policies.assert_called_once_with(page=3, page_size=25)
        assert len(result.outputs) == 2

    def test_policy_list_auto_paginate_default_limit(self, mock_client, policies_response, mocker):
        """Test koi-policy-list in auto-paginate mode with default limit (no args)."""
        mocker.patch.object(mock_client, "get_policies", return_value=policies_response)

        args: dict[str, str] = {}
        result = koi_policy_list_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Policy"
        assert len(result.outputs) == 2

    def test_policy_list_auto_paginate_custom_limit(self, mock_client, mocker):
        """Test koi-policy-list auto-paginate with custom limit across multiple pages."""
        page1 = {"policies": [{"id": i} for i in range(Config.MAX_PAGE_SIZE)], "total_count": 600}
        page2 = {"policies": [{"id": i + Config.MAX_PAGE_SIZE} for i in range(100)], "total_count": 600}

        mocker.patch.object(mock_client, "get_policies", side_effect=[page1, page2])

        args = {"limit": "600"}
        result = koi_policy_list_command(mock_client, args)

        assert len(result.outputs) == 600
        assert mock_client.get_policies.call_count == 2

    def test_policy_list_auto_paginate_stops_on_empty(self, mock_client, mocker):
        """Test auto-paginate stops when API returns empty page after a full page."""
        # Page 1 returns a full page (MAX_PAGE_SIZE) so pagination continues
        page1 = {"policies": [{"id": i} for i in range(Config.MAX_PAGE_SIZE)], "total_count": 500}
        # Page 2 returns empty — pagination stops
        page2 = {"policies": [], "total_count": 500}

        mocker.patch.object(mock_client, "get_policies", side_effect=[page1, page2])

        args = {"limit": "1000"}
        result = koi_policy_list_command(mock_client, args)

        assert len(result.outputs) == Config.MAX_PAGE_SIZE
        assert mock_client.get_policies.call_count == 2

    def test_policy_list_auto_paginate_stops_on_partial_page(self, mock_client, mocker):
        """Test auto-paginate stops when API returns fewer results than page_size."""
        # Return fewer than MAX_PAGE_SIZE items — indicates last page
        partial_page = {"policies": [{"id": i} for i in range(50)], "total_count": 50}

        mocker.patch.object(mock_client, "get_policies", return_value=partial_page)

        args = {"limit": "500"}
        result = koi_policy_list_command(mock_client, args)

        assert len(result.outputs) == 50
        mock_client.get_policies.assert_called_once()

    def test_policy_list_auto_paginate_trims_to_limit(self, mock_client, mocker):
        """Test auto-paginate trims results to the requested limit."""
        # Return a full page of MAX_PAGE_SIZE items
        full_page = {"policies": [{"id": i} for i in range(Config.MAX_PAGE_SIZE)], "total_count": 1000}

        mocker.patch.object(mock_client, "get_policies", return_value=full_page)

        args = {"limit": "10"}
        result = koi_policy_list_command(mock_client, args)

        assert len(result.outputs) == 10

    def test_policy_list_empty_response(self, mock_client, mocker):
        """Test koi-policy-list when no policies are returned."""
        mocker.patch.object(mock_client, "get_policies", return_value={"policies": [], "total_count": 0})

        args: dict[str, str] = {}
        result = koi_policy_list_command(mock_client, args)

        assert result.outputs == []
        assert "Policies" in result.readable_output

    def test_policy_list_outputs_and_readable(self, mock_client, policies_response, mocker):
        """Test that all expected fields are present in outputs and readable output contains data."""
        mocker.patch.object(mock_client, "get_policies", return_value=policies_response)

        args = {"page": "1"}
        result = koi_policy_list_command(mock_client, args)

        # Verify readable output contains key data
        assert "My Policy" in result.readable_output
        assert "block" in result.readable_output
        assert "John Doe" in result.readable_output

        # Verify all fields in outputs
        policy = result.outputs[0]
        assert policy["id"] == 1
        assert policy["name"] == "My Policy"
        assert policy["description"] == "This policy blocks high-risk extensions"
        assert policy["action"] == "block"
        assert policy["enabled"] is True
        assert policy["group_ids"] == [1, 2, 3]
        assert policy["creator_fullname"] == "John Doe"
        assert policy["created_at"] == "2025-04-23T17:22:24.023Z"
        assert policy["updated_at"] == "2025-04-23T17:22:24.023Z"


class TestClientGetPolicies:
    """Tests for the Client.get_policies method."""

    def test_get_policies_params(self, mock_client, policies_response, mocker):
        """Test that get_policies passes correct params and does not send limit to the API."""
        mocker.patch.object(mock_client, "_http_request", return_value=policies_response)

        result = mock_client.get_policies(page=2, page_size=50)

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "GET"
        assert call_kwargs["url_suffix"] == ApiPaths.POLICIES
        assert call_kwargs["params"]["page"] == 2
        assert call_kwargs["params"]["page_size"] == 50
        assert "limit" not in call_kwargs["params"]
        assert result == policies_response

    def test_policy_list_page_size_exceeds_max_raises_error(self, mock_client, mocker):
        """Test that page_size exceeding MAX_PAGE_SIZE raises ValueError."""
        args = {"page": "1", "page_size": "501"}
        with pytest.raises(DemistoException, match="page_size .* exceeds the maximum allowed value"):
            koi_policy_list_command(mock_client, args)

    def test_policy_list_limit_exceeds_max_raises_error(self, mock_client, mocker):
        """Test that limit exceeding MAX_LIMIT raises ValueError."""
        args = {"limit": "1001"}
        with pytest.raises(DemistoException, match="limit .* exceeds the maximum allowed value"):
            koi_policy_list_command(mock_client, args)


# endregion

# region Allowlist command tests


class TestKoiAllowlistGetCommand:
    """Tests for the koi-allowlist-get command."""

    def test_allowlist_get_returns_items(self, mock_client, allowlist_response, mocker):
        """Test koi-allowlist-get returns all allowlist items."""
        mocker.patch.object(mock_client, "get_allowlist", return_value=allowlist_response)

        args: dict[str, str] = {}
        result = koi_allowlist_get_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Allowlist"
        assert result.outputs_key_field == "item_id"
        assert len(result.outputs) == 2
        assert result.outputs[0]["item_id"] == "ext-123"
        assert result.outputs[0]["item_name"] == "My Extension"
        assert result.outputs[1]["item_id"] == "ext-456"

    def test_allowlist_get_empty_response(self, mock_client, mocker):
        """Test koi-allowlist-get when no items are returned."""
        mocker.patch.object(mock_client, "get_allowlist", return_value={"items": []})

        args: dict[str, str] = {}
        result = koi_allowlist_get_command(mock_client, args)

        assert result.outputs == []
        assert "Allowlist" in result.readable_output

    def test_allowlist_get_outputs_and_readable(self, mock_client, allowlist_response, mocker):
        """Test that all expected fields are present in outputs and readable output contains data."""
        mocker.patch.object(mock_client, "get_allowlist", return_value=allowlist_response)

        args: dict[str, str] = {}
        result = koi_allowlist_get_command(mock_client, args)

        # Verify readable output contains key data
        assert "My Extension" in result.readable_output
        assert "admin@example.com" in result.readable_output
        assert "vscode" in result.readable_output

        # Verify all fields in outputs
        item = result.outputs[0]
        assert item["item_id"] == "ext-123"
        assert item["item_name"] == "My Extension"
        assert item["item_display_name"] == "My Extension Display Name"
        assert item["marketplace"] == "vscode"
        assert item["publisher_name"] == "My Publisher"
        assert item["package_name"] == "my-package"
        assert item["notes"] == "Approved for development purposes"
        assert item["created_by"] == "admin@example.com"
        assert item["created_at"] == "2025-04-23T17:22:24.023Z"


class TestClientGetAllowlist:
    """Tests for the Client.get_allowlist method."""

    def test_get_allowlist_params(self, mock_client, allowlist_response, mocker):
        """Test that get_allowlist calls the correct endpoint with no params."""
        mocker.patch.object(mock_client, "_http_request", return_value=allowlist_response)

        result = mock_client.get_allowlist()

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "GET"
        assert call_kwargs["url_suffix"] == ApiPaths.ALLOWLIST
        assert "params" not in call_kwargs
        assert result == allowlist_response

    def test_get_allowlist_empty(self, mock_client, mocker):
        """Test get_allowlist with empty response."""
        empty_response = {"items": []}
        mocker.patch.object(mock_client, "_http_request", return_value=empty_response)

        result = mock_client.get_allowlist()

        assert result == empty_response
        assert result["items"] == []


# endregion

# region Allowlist item remove command tests


class TestKoiAllowlistItemRemoveCommand:
    """Tests for the koi-allowlist-items-remove command."""

    def test_allowlist_item_remove_single_item(self, mock_client, mocker):
        """Test koi-allowlist-items-remove successfully removes a single item."""
        mocker.patch.object(mock_client, "remove_allowlist_items", return_value=None)

        args = {"item_id": "ext-123", "marketplace": "vscode"}
        result = koi_allowlist_items_remove_command(mock_client, args)

        assert "was removed successfully" in result.readable_output
        assert "ext-123" in result.readable_output
        assert result.outputs is None
        mock_client.remove_allowlist_items.assert_called_once_with([{"item_id": "ext-123", "marketplace": "vscode"}])

    @pytest.mark.parametrize(
        "items_data, expected_readable",
        [
            (
                [
                    {"item_id": "ext-1", "marketplace": "vscode"},
                    {"item_id": "ext-2", "marketplace": "npm", "created_by": "user@example.com"},
                ],
                "2 allowlist items were removed successfully",
            ),
            (
                [{"item_id": "ext-1", "marketplace": "vscode"}],
                "ext-1",
            ),
        ],
        ids=["multiple_items", "single_item"],
    )
    def test_allowlist_item_remove_from_file(self, mock_client, mocker, tmp_path, items_data, expected_readable):
        """Test koi-allowlist-items-remove from a JSON file entry ID."""
        mocker.patch.object(mock_client, "remove_allowlist_items", return_value=None)

        json_file = tmp_path / "items.json"
        json_file.write_text(json.dumps(items_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "items.json"})

        args = {"items_list_raw_json_entry_id": "entry-abc-123"}
        result = koi_allowlist_items_remove_command(mock_client, args)

        assert expected_readable in result.readable_output
        mock_client.remove_allowlist_items.assert_called_once_with(items_data)


class TestClientRemoveAllowlistItems:
    """Tests for the Client.remove_allowlist_items method."""

    @pytest.mark.parametrize(
        "items",
        [
            [{"item_id": "ext-123", "marketplace": "vscode"}],
            [
                {"item_id": "ext-123", "marketplace": "vscode"},
                {"item_id": "ext-456", "marketplace": "npm"},
            ],
        ],
        ids=["single_item", "multiple_items"],
    )
    def test_remove_allowlist_items_request(self, mock_client, mocker, items):
        """Test remove_allowlist_items sends correct DELETE request."""
        mock_response = mocker.MagicMock()
        mock_response.status_code = 204
        mocker.patch.object(mock_client, "_http_request", return_value=mock_response)

        mock_client.remove_allowlist_items(items)

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "DELETE"
        assert call_kwargs["url_suffix"] == ApiPaths.ALLOWLIST
        assert call_kwargs["json_data"] == {"items": items}
        assert call_kwargs["resp_type"] == "response"
        assert call_kwargs["ok_codes"] == (204,)

    def test_remove_allowlist_items_api_error(self, mock_client, mocker):
        """Test that remove_allowlist_items propagates API errors."""
        mocker.patch.object(
            mock_client,
            "_http_request",
            side_effect=DemistoException("Error in API call [404] - Not Found"),
        )

        with pytest.raises(DemistoException, match="Error in API call"):
            mock_client.remove_allowlist_items([{"item_id": "nonexistent", "marketplace": "vscode"}])


# endregion

# region Allowlist item add command tests


class TestKoiAllowlistItemAddCommand:
    """Tests for the koi-allowlist-items-add command."""

    def test_allowlist_item_add_single_item(self, mock_client, mocker):
        """Test koi-allowlist-items-add successfully adds a single item."""
        mocker.patch.object(mock_client, "add_allowlist_items", return_value=None)

        args = {"item_id": "ext-123", "marketplace": "vscode"}
        result = koi_allowlist_items_add_command(mock_client, args)

        assert "was added successfully" in result.readable_output
        assert "ext-123" in result.readable_output
        assert result.outputs is None
        mock_client.add_allowlist_items.assert_called_once_with([{"item_id": "ext-123", "marketplace": "vscode"}])

    def test_allowlist_item_add_from_file(self, mock_client, mocker, tmp_path):
        """Test koi-allowlist-items-add from a JSON file entry ID."""
        mocker.patch.object(mock_client, "add_allowlist_items", return_value=None)

        items_data = [
            {"item_id": "ext-1", "marketplace": "vscode"},
            {"item_id": "ext-2", "marketplace": "npm", "created_by": "user@example.com"},
        ]
        json_file = tmp_path / "items.json"
        json_file.write_text(json.dumps(items_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "items.json"})

        args = {"items_list_raw_json_entry_id": "entry-abc-123"}
        result = koi_allowlist_items_add_command(mock_client, args)

        assert "2 allowlist items were added successfully" in result.readable_output
        mock_client.add_allowlist_items.assert_called_once_with(items_data)

    def test_allowlist_item_add_from_file_single_item(self, mock_client, mocker, tmp_path):
        """Test koi-allowlist-items-add from a JSON file with a single item uses singular message."""
        mocker.patch.object(mock_client, "add_allowlist_items", return_value=None)

        items_data = [{"item_id": "ext-1", "marketplace": "vscode"}]
        json_file = tmp_path / "items.json"
        json_file.write_text(json.dumps(items_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "items.json"})

        args = {"items_list_raw_json_entry_id": "entry-abc-123"}
        result = koi_allowlist_items_add_command(mock_client, args)

        assert "ext-1" in result.readable_output
        assert "vscode" in result.readable_output
        assert "was added successfully" in result.readable_output


class TestParseItemsFromEntryId:
    """Tests for the parse_list_items_from_entry_id helper function."""

    def test_parse_valid_items(self, mocker, tmp_path):
        """Test parsing a valid JSON file with multiple items."""
        items_data = [
            {"item_id": "ext-1", "marketplace": "vscode"},
            {"item_id": "ext-2", "marketplace": "npm", "notes": "test"},
        ]
        json_file = tmp_path / "items.json"
        json_file.write_text(json.dumps(items_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "items.json"})

        result = parse_list_items_from_entry_id("entry-123")
        assert result == items_data

    @pytest.mark.parametrize(
        "file_content, error_match",
        [
            ("not valid json {{{", "Failed to parse JSON"),
            (json.dumps({"item_id": "ext-1", "marketplace": "vscode"}), "expected a list of items"),
            (json.dumps([{"item_id": "ext-1"}]), "must contain 'item_id' and 'marketplace'"),
            (json.dumps([{"item_id": "ext-1", "marketplace": "invalid_store"}]), "Invalid marketplace"),
            (json.dumps(["not-a-dict"]), "expected a dictionary"),
        ],
        ids=[
            "invalid_json",
            "not_a_list",
            "missing_required_fields",
            "invalid_marketplace",
            "item_not_a_dict",
        ],
    )
    def test_parse_invalid_file_content(self, mocker, tmp_path, file_content, error_match):
        """Test that invalid file content raises DemistoException with the expected message."""
        json_file = tmp_path / "items.json"
        json_file.write_text(file_content)

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "items.json"})

        with pytest.raises(DemistoException, match=error_match):
            parse_list_items_from_entry_id("entry-123")

    @pytest.mark.parametrize(
        "mock_kwargs, error_match",
        [
            ({"side_effect": Exception("Entry not found")}, "Could not find file"),
            ({"return_value": {}}, "not a valid file entry"),
        ],
        ids=["entry_not_found", "entry_not_a_file"],
    )
    def test_parse_entry_resolution_errors(self, mocker, mock_kwargs, error_match):
        """Test that entry resolution errors raise DemistoException."""
        mocker.patch.object(demisto, "getFilePath", **mock_kwargs)

        with pytest.raises(DemistoException, match=error_match):
            parse_list_items_from_entry_id("entry-123")


class TestClientAddAllowlistItems:
    """Tests for the Client.add_allowlist_items method."""

    def test_add_allowlist_items_single_item(self, mock_client, mocker):
        """Test that add_allowlist_items sends correct POST request with a single item."""
        mock_response = mocker.MagicMock()
        mock_response.status_code = 204
        mocker.patch.object(mock_client, "_http_request", return_value=mock_response)

        items = [{"item_id": "ext-123", "marketplace": "vscode"}]
        mock_client.add_allowlist_items(items)

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "POST"
        assert call_kwargs["url_suffix"] == ApiPaths.ALLOWLIST
        assert call_kwargs["json_data"] == {"items": items}
        assert call_kwargs["resp_type"] == "response"
        assert call_kwargs["ok_codes"] == (204,)

    def test_add_allowlist_items_multiple_items(self, mock_client, mocker):
        """Test that add_allowlist_items sends multiple items in the body."""
        mock_response = mocker.MagicMock()
        mock_response.status_code = 204
        mocker.patch.object(mock_client, "_http_request", return_value=mock_response)

        items = [
            {"item_id": "ext-1", "marketplace": "vscode"},
            {"item_id": "ext-2", "marketplace": "npm", "created_by": "admin@example.com"},
            {"item_id": "ext-3", "marketplace": "chrome_web_store", "notes": "Approved"},
        ]
        mock_client.add_allowlist_items(items)

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["json_data"] == {"items": items}

    def test_add_allowlist_items_api_error(self, mock_client, mocker):
        """Test that add_allowlist_items propagates API errors."""
        mocker.patch.object(
            mock_client,
            "_http_request",
            side_effect=DemistoException("Error in API call [400] - Bad Request"),
        )

        with pytest.raises(DemistoException, match="Error in API call"):
            mock_client.add_allowlist_items([{"item_id": "bad-item", "marketplace": "vscode"}])


# endregion

# region koi-blocklist-get tests


class TestKoiBlocklistGetCommand:
    """Tests for the koi-blocklist-get command."""

    def test_blocklist_get_returns_items(self, mock_client, blocklist_response, mocker):
        """Test koi-blocklist-get returns all blocklist items."""
        mocker.patch.object(mock_client, "get_blocklist", return_value=blocklist_response)

        args: dict[str, str] = {}
        result = koi_blocklist_get_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Blocklist"
        assert result.outputs_key_field == "item_id"
        assert len(result.outputs) == 2
        assert result.outputs[0]["item_id"] == "mal-001"
        assert result.outputs[0]["item_name"] == "Bad Extension"
        assert result.outputs[1]["item_id"] == "mal-002"

    def test_blocklist_get_empty_response(self, mock_client, mocker):
        """Test koi-blocklist-get when no items are returned."""
        mocker.patch.object(mock_client, "get_blocklist", return_value={"items": []})

        args: dict[str, str] = {}
        result = koi_blocklist_get_command(mock_client, args)

        assert result.outputs == []
        assert "Blocklist" in result.readable_output

    def test_blocklist_get_outputs_and_readable(self, mock_client, blocklist_response, mocker):
        """Test that all expected fields are present in outputs and readable output contains data."""
        mocker.patch.object(mock_client, "get_blocklist", return_value=blocklist_response)

        args: dict[str, str] = {}
        result = koi_blocklist_get_command(mock_client, args)

        # Verify readable output contains key data
        assert "Bad Extension" in result.readable_output
        assert "security@example.com" in result.readable_output
        assert "chrome_web_store" in result.readable_output

        # Verify all fields in outputs
        item = result.outputs[0]
        assert item["item_id"] == "mal-001"
        assert item["item_name"] == "Bad Extension"
        assert item["item_display_name"] == "Malicious Extension"
        assert item["marketplace"] == "chrome_web_store"
        assert item["publisher_name"] == "Suspicious Publisher"
        assert item["package_name"] == "bad-package"
        assert item["notes"] == "Known malware distribution"
        assert item["created_by"] == "security@example.com"
        assert item["created_at"] == "2025-05-01T09:15:00.000Z"


class TestClientGetBlocklist:
    """Tests for the Client.get_blocklist method."""

    def test_get_blocklist_params(self, mock_client, blocklist_response, mocker):
        """Test that get_blocklist calls the correct endpoint with no params."""
        mocker.patch.object(mock_client, "_http_request", return_value=blocklist_response)

        result = mock_client.get_blocklist()

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "GET"
        assert call_kwargs["url_suffix"] == ApiPaths.BLOCKLIST
        assert "params" not in call_kwargs
        assert result == blocklist_response

    def test_get_blocklist_empty(self, mock_client, mocker):
        """Test get_blocklist with empty response."""
        empty_response = {"items": []}
        mocker.patch.object(mock_client, "_http_request", return_value=empty_response)

        result = mock_client.get_blocklist()

        assert result == empty_response
        assert result["items"] == []


# endregion

# region koi-blocklist-items-remove tests


class TestKoiBlocklistItemRemoveCommand:
    """Tests for the koi-blocklist-items-remove command."""

    def test_blocklist_item_remove_single_item(self, mock_client, mocker):
        """Test koi-blocklist-items-remove successfully removes a single item."""
        mocker.patch.object(mock_client, "remove_blocklist_items", return_value=None)

        args = {"item_id": "mal-001", "marketplace": "vscode"}
        result = koi_blocklist_items_remove_command(mock_client, args)

        assert "was removed successfully" in result.readable_output
        assert "mal-001" in result.readable_output
        assert result.outputs is None
        mock_client.remove_blocklist_items.assert_called_once_with([{"item_id": "mal-001", "marketplace": "vscode"}])

    @pytest.mark.parametrize(
        "items_data, expected_readable",
        [
            (
                [
                    {"item_id": "mal-1", "marketplace": "vscode"},
                    {"item_id": "mal-2", "marketplace": "npm", "created_by": "user@example.com"},
                ],
                "2 blocklist items were removed successfully",
            ),
            (
                [{"item_id": "mal-1", "marketplace": "vscode"}],
                "mal-1",
            ),
        ],
        ids=["multiple_items", "single_item"],
    )
    def test_blocklist_item_remove_from_file(self, mock_client, mocker, tmp_path, items_data, expected_readable):
        """Test koi-blocklist-items-remove from a JSON file entry ID."""
        mocker.patch.object(mock_client, "remove_blocklist_items", return_value=None)

        json_file = tmp_path / "items.json"
        json_file.write_text(json.dumps(items_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "items.json"})

        args = {"items_list_raw_json_entry_id": "entry-abc-123"}
        result = koi_blocklist_items_remove_command(mock_client, args)

        assert expected_readable in result.readable_output
        mock_client.remove_blocklist_items.assert_called_once_with(items_data)


class TestClientRemoveBlocklistItems:
    """Tests for the Client.remove_blocklist_items method."""

    @pytest.mark.parametrize(
        "items",
        [
            [{"item_id": "mal-001", "marketplace": "vscode"}],
            [
                {"item_id": "mal-001", "marketplace": "vscode"},
                {"item_id": "mal-002", "marketplace": "npm"},
            ],
        ],
        ids=["single_item", "multiple_items"],
    )
    def test_remove_blocklist_items_request(self, mock_client, mocker, items):
        """Test remove_blocklist_items sends correct DELETE request."""
        mock_response = mocker.MagicMock()
        mock_response.status_code = 204
        mocker.patch.object(mock_client, "_http_request", return_value=mock_response)

        mock_client.remove_blocklist_items(items)

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "DELETE"
        assert call_kwargs["url_suffix"] == ApiPaths.BLOCKLIST
        assert call_kwargs["json_data"] == {"items": items}
        assert call_kwargs["resp_type"] == "response"
        assert call_kwargs["ok_codes"] == (204,)

    def test_remove_blocklist_items_api_error(self, mock_client, mocker):
        """Test that remove_blocklist_items propagates API errors."""
        mocker.patch.object(
            mock_client,
            "_http_request",
            side_effect=DemistoException("Error in API call [400] - Bad Request"),
        )

        with pytest.raises(DemistoException, match="Error in API call"):
            mock_client.remove_blocklist_items([{"item_id": "bad-item", "marketplace": "vscode"}])


# endregion

# region koi-blocklist-items-add tests


class TestKoiBlocklistItemAddCommand:
    """Tests for the koi-blocklist-items-add command."""

    def test_blocklist_item_add_single_item(self, mock_client, mocker):
        """Test koi-blocklist-items-add successfully adds a single item."""
        mocker.patch.object(mock_client, "add_blocklist_items", return_value=None)

        args = {"item_id": "mal-001", "marketplace": "vscode"}
        result = koi_blocklist_items_add_command(mock_client, args)

        assert "was added successfully" in result.readable_output
        assert "mal-001" in result.readable_output
        assert result.outputs is None
        mock_client.add_blocklist_items.assert_called_once_with([{"item_id": "mal-001", "marketplace": "vscode"}])

    @pytest.mark.parametrize(
        "items_data, expected_readable",
        [
            (
                [
                    {"item_id": "mal-1", "marketplace": "vscode"},
                    {"item_id": "mal-2", "marketplace": "npm", "created_by": "user@example.com"},
                ],
                "2 blocklist items were added successfully",
            ),
            (
                [{"item_id": "mal-1", "marketplace": "vscode"}],
                "mal-1",
            ),
        ],
        ids=["multiple_items", "single_item"],
    )
    def test_blocklist_item_add_from_file(self, mock_client, mocker, tmp_path, items_data, expected_readable):
        """Test koi-blocklist-items-add from a JSON file entry ID."""
        mocker.patch.object(mock_client, "add_blocklist_items", return_value=None)

        json_file = tmp_path / "items.json"
        json_file.write_text(json.dumps(items_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "items.json"})

        args = {"items_list_raw_json_entry_id": "entry-abc-123"}
        result = koi_blocklist_items_add_command(mock_client, args)

        assert expected_readable in result.readable_output
        mock_client.add_blocklist_items.assert_called_once_with(items_data)


class TestClientAddBlocklistItems:
    """Tests for the Client.add_blocklist_items method."""

    @pytest.mark.parametrize(
        "items",
        [
            [{"item_id": "mal-001", "marketplace": "vscode"}],
            [
                {"item_id": "mal-001", "marketplace": "vscode"},
                {"item_id": "mal-002", "marketplace": "npm"},
            ],
        ],
        ids=["single_item", "multiple_items"],
    )
    def test_add_blocklist_items_request(self, mock_client, mocker, items):
        """Test add_blocklist_items sends correct POST request."""
        mock_response = mocker.MagicMock()
        mock_response.status_code = 204
        mocker.patch.object(mock_client, "_http_request", return_value=mock_response)

        mock_client.add_blocklist_items(items)

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "POST"
        assert call_kwargs["url_suffix"] == ApiPaths.BLOCKLIST
        assert call_kwargs["json_data"] == {"items": items}
        assert call_kwargs["resp_type"] == "response"
        assert call_kwargs["ok_codes"] == (204,)

    def test_add_blocklist_items_api_error(self, mock_client, mocker):
        """Test that add_blocklist_items propagates API errors."""
        mocker.patch.object(
            mock_client,
            "_http_request",
            side_effect=DemistoException("Error in API call [400] - Bad Request"),
        )

        with pytest.raises(DemistoException, match="Error in API call"):
            mock_client.add_blocklist_items([{"item_id": "bad-item", "marketplace": "vscode"}])


# endregion

# region resolve_items_from_args tests


class TestResolveItemsFromArgs:
    """Tests for the resolve_items_from_args helper function."""

    def test_single_item_required_only(self):
        """Test resolving a single item with required fields only."""
        args = {"item_id": "ext-123", "marketplace": "vscode"}
        result = resolve_items_from_args(args)

        assert result == [{"item_id": "ext-123", "marketplace": "vscode"}]

    def test_single_item_with_optional_params(self):
        """Test resolving a single item with optional created_by and notes."""
        args = {
            "item_id": "ext-123",
            "marketplace": "vscode",
            "created_by": "admin@example.com",
            "notes": "Test note",
        }
        result = resolve_items_from_args(args)

        assert result == [
            {
                "item_id": "ext-123",
                "marketplace": "vscode",
                "created_by": "admin@example.com",
                "notes": "Test note",
            }
        ]

    def test_invalid_marketplace(self):
        """Test that invalid marketplace raises DemistoException."""
        args = {"item_id": "ext-123", "marketplace": "invalid_store"}

        with pytest.raises(DemistoException, match="Invalid marketplace"):
            resolve_items_from_args(args)

    @pytest.mark.parametrize("marketplace", VALID_MARKETPLACES)
    def test_all_valid_marketplaces(self, marketplace):
        """Test that all valid marketplace values are accepted."""
        args = {"item_id": "test-item", "marketplace": marketplace}
        result = resolve_items_from_args(args)

        assert result == [{"item_id": "test-item", "marketplace": marketplace}]

    @pytest.mark.parametrize(
        "args",
        [
            {},
            {"item_id": "ext-123"},
            {"marketplace": "vscode"},
        ],
        ids=["no_args", "missing_marketplace", "missing_item_id"],
    )
    def test_missing_required_args(self, args):
        """Test that missing item_id/marketplace pair raises DemistoException."""
        with pytest.raises(DemistoException, match="Either 'item_id' and 'marketplace' must be provided"):
            resolve_items_from_args(args)

    def test_file_entry_id_takes_priority(self, mocker, tmp_path):
        """Test that file entry ID takes priority over single item args."""
        items_data = [{"item_id": "file-item", "marketplace": "npm"}]
        json_file = tmp_path / "items.json"
        json_file.write_text(json.dumps(items_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "items.json"})

        args = {
            "items_list_raw_json_entry_id": "entry-abc-123",
            "item_id": "arg-item",
            "marketplace": "vscode",
        }
        result = resolve_items_from_args(args)

        assert result == items_data


# endregion

# region koi-policy-status-update tests


class TestKoiPolicyStatusUpdateCommand:
    """Tests for the koi-policy-status-update command."""

    @pytest.mark.parametrize(
        "enabled_arg, expected_enabled, expected_text",
        [
            ("true", True, "enabled"),
            ("false", False, "disabled"),
        ],
        ids=["enable", "disable"],
    )
    def test_policy_status_update(
        self, mock_client, policy_update_response, mocker, enabled_arg, expected_enabled, expected_text
    ):
        """Test koi-policy-status-update enables or disables a policy."""
        response = {**policy_update_response, "enabled": expected_enabled}
        mocker.patch.object(mock_client, "update_policy_status", return_value=response)

        args = {"policy_id": "1", "enabled": enabled_arg}
        result = koi_policy_status_update_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Policy"
        assert result.outputs_key_field == "id"
        assert result.outputs["id"] == 1
        assert result.outputs["enabled"] == expected_enabled
        assert "Policy Updated" in result.readable_output
        mock_client.update_policy_status.assert_called_once_with(policy_id=1, enabled=expected_enabled)

    def test_policy_status_update_outputs_and_readable(self, mock_client, policy_update_response, mocker):
        """Test that all expected fields are present in outputs and readable output."""
        mocker.patch.object(mock_client, "update_policy_status", return_value=policy_update_response)

        args = {"policy_id": "1", "enabled": "true"}
        result = koi_policy_status_update_command(mock_client, args)

        # Verify all fields in outputs
        assert result.outputs["id"] == 1
        assert result.outputs["name"] == "My Policy"
        assert result.outputs["description"] == "This policy blocks high-risk extensions"
        assert result.outputs["action"] == "block"
        assert result.outputs["enabled"] is True
        assert result.outputs["group_ids"] == [1, 2, 3]
        assert result.outputs["creator_fullname"] == "John Doe"

        # Verify readable output contains key data
        assert "My Policy" in result.readable_output
        assert "block" in result.readable_output

    @pytest.mark.parametrize(
        "args",
        [
            {"policy_id": "abc", "enabled": "true"},
            {"policy_id": "1", "enabled": "not_a_bool"},
            {"enabled": "true"},
            {"policy_id": "1"},
            {},
        ],
        ids=["invalid_policy_id", "invalid_enabled", "missing_policy_id", "missing_enabled", "no_args"],
    )
    def test_policy_status_update_invalid_input(self, mock_client, args):
        """Test koi-policy-status-update raises error for invalid or missing input."""
        with pytest.raises((ValueError, KeyError, DemistoException)):
            koi_policy_status_update_command(mock_client, args)


class TestClientUpdatePolicyStatus:
    """Tests for the Client.update_policy_status method."""

    @pytest.mark.parametrize("enabled", [True, False], ids=["enable", "disable"])
    def test_update_policy_status_request(self, mock_client, policy_update_response, mocker, enabled):
        """Test update_policy_status sends correct PUT request."""
        mocker.patch.object(mock_client, "_http_request", return_value=policy_update_response)

        result = mock_client.update_policy_status(policy_id=42, enabled=enabled)

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "PUT"
        assert call_kwargs["url_suffix"] == ApiPaths.policy(42)
        assert call_kwargs["json_data"] == {"enabled": enabled}
        assert result == policy_update_response

    def test_update_policy_status_api_error(self, mock_client, mocker):
        """Test that update_policy_status propagates API errors."""
        mocker.patch.object(
            mock_client,
            "_http_request",
            side_effect=DemistoException("Error in API call [404] - Not Found"),
        )

        with pytest.raises(DemistoException, match="Error in API call"):
            mock_client.update_policy_status(policy_id=999, enabled=True)


# endregion

# region koi-inventory-list tests


class TestKoiInventoryListCommand:
    """Tests for the koi-inventory-list command."""

    @pytest.mark.parametrize(
        "args, expected_page, expected_page_size",
        [
            ({"page": "1"}, 1, Config.DEFAULT_PAGE_SIZE),
            ({"page": "2", "page_size": "50"}, 2, 50),
            ({"page": "3", "page_size": "25", "limit": "200"}, 3, 25),
        ],
        ids=["default_page_size", "custom_page_size", "limit_ignored_when_page_provided"],
    )
    def test_inventory_list_single_page_mode(
        self, mock_client, inventory_response, mocker, args, expected_page, expected_page_size
    ):
        """Test koi-inventory-list in single-page mode with various argument combinations."""
        mocker.patch.object(mock_client, "get_inventory", return_value=inventory_response)

        result = koi_inventory_list_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Inventory"
        assert result.outputs_key_field == "item_id"
        assert len(result.outputs) == 2
        mock_client.get_inventory.assert_called_once_with(page=expected_page, page_size=expected_page_size)

    def test_inventory_list_auto_paginate_default_limit(self, mock_client, inventory_response, mocker):
        """Test koi-inventory-list in auto-paginate mode with default limit (no args)."""
        mocker.patch.object(mock_client, "get_inventory", return_value=inventory_response)

        args: dict[str, str] = {}
        result = koi_inventory_list_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Inventory"
        assert len(result.outputs) == 2

    @pytest.mark.parametrize(
        "limit_arg, api_item_count, expected_output_count",
        [
            ("500", Config.MAX_PAGE_SIZE, 500),
            ("500", 0, 0),
            ("500", 50, 50),
            ("10", Config.MAX_PAGE_SIZE, 10),
        ],
        ids=[
            "full_page_satisfies_limit",
            "empty_response",
            "partial_page_stops_pagination",
            "trims_to_limit",
        ],
    )
    def test_inventory_list_auto_paginate_behavior(self, mock_client, mocker, limit_arg, api_item_count, expected_output_count):
        """Test auto-paginate behavior with various limit and API response combinations."""
        response = {
            "items": [{"item_id": f"item-{i}"} for i in range(api_item_count)],
            "total_count": api_item_count,
        }
        mocker.patch.object(mock_client, "get_inventory", return_value=response)

        args = {"limit": limit_arg}
        result = koi_inventory_list_command(mock_client, args)

        assert len(result.outputs) == expected_output_count

    def test_inventory_list_empty_response(self, mock_client, mocker):
        """Test koi-inventory-list when no items are returned."""
        mocker.patch.object(mock_client, "get_inventory", return_value={"items": [], "total_count": 0})

        args: dict[str, str] = {}
        result = koi_inventory_list_command(mock_client, args)

        assert result.outputs == []
        assert "Inventory" in result.readable_output

    @pytest.mark.parametrize(
        "filter_args, expected_call_kwargs",
        [
            (
                {
                    "marketplace": "chrome_web_store",
                    "risk_level": "high",
                    "publisher_name": "Meta",
                    "platform": "chrome",
                    "view": "extensions",
                    "sort_by": "risk_level",
                    "sort_direction": "desc",
                },
                {
                    "marketplace": "chrome_web_store",
                    "risk_level": "high",
                    "publisher_name": "Meta",
                    "platform": "chrome",
                    "view": "extensions",
                    "sort_by": "risk_level",
                    "sort_direction": "desc",
                },
            ),
            (
                {
                    "brew_category_koi": "Command Line Tools & Utilities",
                    "browser_category_koi": "Developer Tools",
                    "chocolatey_category_koi": "Command Line Tools & Utilities",
                    "ide_category_koi": "Language Support & Tooling",
                    "software_category_koi": "Docs tools",
                },
                {
                    "brew_category_koi": "Command Line Tools & Utilities",
                    "browser_category_koi": "Developer Tools",
                    "chocolatey_category_koi": "Command Line Tools & Utilities",
                    "ide_category_koi": "Language Support & Tooling",
                    "software_category_koi": "Docs tools",
                },
            ),
            (
                {
                    "device_id": "550e8400-e29b-41d4-a716-446655440000",
                    "finding_id": "550e8400-e29b-41d4-a716-446655440001",
                    "item_id": "f53b1d43-eef4-4909-99ca-56b5fa3e108c",
                },
                {
                    "device_id": "550e8400-e29b-41d4-a716-446655440000",
                    "finding_id": "550e8400-e29b-41d4-a716-446655440001",
                    "item_id": "f53b1d43-eef4-4909-99ca-56b5fa3e108c",
                },
            ),
        ],
        ids=["marketplace_and_sorting_filters", "category_filters", "id_filters"],
    )
    def test_inventory_list_with_filters(self, mock_client, inventory_response, mocker, filter_args, expected_call_kwargs):
        """Test koi-inventory-list passes various filter arguments to the client."""
        mocker.patch.object(mock_client, "get_inventory", return_value=inventory_response)

        args = {"page": "1", **filter_args}
        result = koi_inventory_list_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Inventory"
        mock_client.get_inventory.assert_called_once_with(
            page=1,
            page_size=Config.DEFAULT_PAGE_SIZE,
            **expected_call_kwargs,
        )

    def test_inventory_list_outputs_and_readable(self, mock_client, inventory_response, mocker):
        """Test that all expected fields are present in outputs and readable output contains data."""
        mocker.patch.object(mock_client, "get_inventory", return_value=inventory_response)

        args = {"page": "1"}
        result = koi_inventory_list_command(mock_client, args)

        # Verify readable output contains key data
        assert "React Developer Tools" in result.readable_output
        assert "Meta" in result.readable_output
        assert "chrome_web_store" in result.readable_output
        assert "marketplace" in result.readable_output
        assert "React debugging tools" in result.readable_output
        assert "2025-06-15T10:00:00Z" in result.readable_output
        assert "2023-01-15" in result.readable_output

        # Verify all fields in outputs
        item = result.outputs[0]
        assert item["item_id"] == "abc123"
        assert item["item_display_name"] == "React Developer Tools"
        assert item["marketplace"] == "chrome_web_store"
        assert item["platforms"] == ["chrome", "edge"]
        assert item["publisher_name"] == "Meta"
        assert item["risk"] == 5
        assert item["risk_level"] == "high"
        assert item["version"] == "1.0.0"
        assert item["status"] == "APPROVED"
        assert item["endpoint_count"] == 42
        assert item["installs_count"] == 1000000
        assert item["first_seen"] == "2024-01-01T10:00:00Z"
        assert item["last_seen"] == "2024-10-15T10:00:00Z"
        assert item["last_used"] == "2025-06-15T10:00:00Z"
        assert item["installation_method"] == "marketplace"
        assert item["is_first_party"] is False
        assert item["is_signed"] is True
        assert item["short_description"] == "React debugging tools"
        assert item["categories"] == ["Developer Tools"]
        assert item["findings"] == ["malware", "permissions"]
        assert item["released_at"] == "2023-01-15"
        assert item["governed_details"] == {"group-uuid-123": {"policy_id": "policy-uuid-456", "action": "allow"}}
        assert item["brew_category_koi"] == "Command Line Tools & Utilities"
        assert item["browser_category_koi"] == "Developer Tools"
        assert item["chocolatey_category_koi"] == "Command Line Tools & Utilities"
        assert item["ide_category_koi"] == "Language Support & Tooling"
        assert item["software_category_koi"] == "Docs tools"

    def test_inventory_list_auto_paginate_passes_filters(self, mock_client, mocker):
        """Test that auto-paginate mode passes filter arguments to each page request."""
        page1 = {"items": [{"item_id": "item-1"}], "total_count": 1}

        mocker.patch.object(mock_client, "get_inventory", return_value=page1)

        args = {"limit": "10", "marketplace": "vscode", "risk_level": "high"}
        koi_inventory_list_command(mock_client, args)

        call_kwargs = mock_client.get_inventory.call_args[1]
        assert call_kwargs["marketplace"] == "vscode"
        assert call_kwargs["risk_level"] == "high"


class TestClientGetInventory:
    """Tests for the Client.get_inventory method."""

    def test_get_inventory_params(self, mock_client, inventory_response, mocker):
        """Test that get_inventory passes correct params to the API."""
        mocker.patch.object(mock_client, "_http_request", return_value=inventory_response)

        result = mock_client.get_inventory(page=2, page_size=50, marketplace="vscode", risk_level="high")

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "GET"
        assert call_kwargs["url_suffix"] == ApiPaths.INVENTORY
        assert call_kwargs["params"]["page"] == 2
        assert call_kwargs["params"]["page_size"] == 50
        assert call_kwargs["params"]["marketplace"] == "vscode"
        assert call_kwargs["params"]["risk_level"] == "high"
        assert result == inventory_response

    def test_inventory_list_page_size_exceeds_max_raises_error(self, mock_client, mocker):
        """Test that page_size exceeding MAX_PAGE_SIZE raises ValueError."""
        args = {"page": "1", "page_size": "501"}
        with pytest.raises(DemistoException, match="page_size .* exceeds the maximum allowed value"):
            koi_inventory_list_command(mock_client, args)

    def test_inventory_list_limit_exceeds_max_raises_error(self, mock_client, mocker):
        """Test that limit exceeding MAX_LIMIT raises ValueError."""
        args = {"limit": "1001"}
        with pytest.raises(DemistoException, match="limit .* exceeds the maximum allowed value"):
            koi_inventory_list_command(mock_client, args)

    def test_get_inventory_no_optional_params(self, mock_client, inventory_response, mocker):
        """Test that None optional params are excluded from the request (assign_params behavior)."""
        mocker.patch.object(mock_client, "_http_request", return_value=inventory_response)

        mock_client.get_inventory(page=1, page_size=100)

        call_kwargs = mock_client._http_request.call_args[1]
        params = call_kwargs["params"]
        assert "marketplace" not in params
        assert "risk_level" not in params
        assert "platform" not in params
        assert "publisher_name" not in params
        assert "view" not in params

    def test_get_inventory_all_filters(self, mock_client, inventory_response, mocker):
        """Test that all filter parameters are passed correctly."""
        mocker.patch.object(mock_client, "_http_request", return_value=inventory_response)

        mock_client.get_inventory(
            page=1,
            page_size=100,
            brew_category_koi="Command Line Tools & Utilities",
            browser_category_koi="Developer Tools",
            chocolatey_category_koi="Command Line Tools & Utilities",
            device_id="device-123",
            finding_id="finding-456",
            first_seen="2024-01-01T00:00:00Z",
            ide_category_koi="Language Support & Tooling",
            installation_method="marketplace",
            item_display_name="React",
            item_id="abc123",
            marketplace="chrome_web_store",
            platform="chrome",
            publisher_name="Meta",
            risk_level="high",
            software_category_koi="Docs tools",
            sort_by="first_seen",
            sort_direction="asc",
            view="extensions",
        )

        call_kwargs = mock_client._http_request.call_args[1]
        params = call_kwargs["params"]
        assert params["brew_category_koi"] == "Command Line Tools & Utilities"
        assert params["browser_category_koi"] == "Developer Tools"
        assert params["chocolatey_category_koi"] == "Command Line Tools & Utilities"
        assert params["device_id"] == "device-123"
        assert params["finding_id"] == "finding-456"
        assert params["first_seen"] == "2024-01-01T00:00:00Z"
        assert params["ide_category_koi"] == "Language Support & Tooling"
        assert params["installation_method"] == "marketplace"
        assert params["item_display_name"] == "React"
        assert params["item_id"] == "abc123"
        assert params["marketplace"] == "chrome_web_store"
        assert params["platform"] == "chrome"
        assert params["publisher_name"] == "Meta"
        assert params["risk_level"] == "high"
        assert params["software_category_koi"] == "Docs tools"
        assert params["sort_by"] == "first_seen"
        assert params["sort_direction"] == "asc"
        assert params["view"] == "extensions"


# endregion

# region koi-inventory-item-get tests


class TestKoiInventoryItemGetCommand:
    """Tests for the koi-inventory-item-get command."""

    @pytest.mark.parametrize(
        "args, expected_version",
        [
            ({"item_id": "abc123", "marketplace": "chrome_web_store", "version": "1.0.0"}, "1.0.0"),
            ({"item_id": "abc123", "marketplace": "chrome_web_store", "version": "2.0.0"}, "2.0.0"),
        ],
        ids=["version_1", "version_2"],
    )
    def test_inventory_item_get(self, mock_client, inventory_item_response, mocker, args, expected_version):
        """Test koi-inventory-item-get with different versions."""
        mocker.patch.object(mock_client, "get_inventory_item", return_value=inventory_item_response)

        result = koi_inventory_item_get_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Inventory"
        assert result.outputs_key_field == "item_id"
        assert result.outputs["item_id"] == "abc123"
        assert result.outputs["item_display_name"] == "React Developer Tools"
        mock_client.get_inventory_item.assert_called_once_with(
            item_id="abc123",
            marketplace="chrome_web_store",
            version=expected_version,
        )

    def test_inventory_item_get_outputs_and_readable(self, mock_client, inventory_item_response, mocker):
        """Test that all expected fields are present in outputs and readable output."""
        mocker.patch.object(mock_client, "get_inventory_item", return_value=inventory_item_response)

        args = {"item_id": "abc123", "marketplace": "chrome_web_store", "version": "1.0.0"}
        result = koi_inventory_item_get_command(mock_client, args)

        # Verify readable output contains key data
        assert "React Developer Tools" in result.readable_output
        assert "Meta" in result.readable_output
        assert "Inventory Item" in result.readable_output

        # Verify all fields in outputs
        item = result.outputs
        assert item["item_id"] == "abc123"
        assert item["item_display_name"] == "React Developer Tools"
        assert item["marketplace"] == "chrome_web_store"
        assert item["version"] == "1.0.0"
        assert item["platforms"] == ["chrome", "edge"]
        assert item["publisher_name"] == "Meta"
        assert item["risk"] == 5
        assert item["risk_level"] == "high"
        assert item["status"] == "Allowed"
        assert item["endpoint_count"] == 42
        assert item["installs_count"] == 1000000
        assert item["installation_method"] == "marketplace"
        assert item["is_first_party"] is False
        assert item["is_signed"] is True
        assert item["first_seen"] == "2024-01-01T10:00:00Z"
        assert item["last_seen"] == "2024-10-15T10:00:00Z"
        assert item["last_used"] == "2025-06-15T10:00:00Z"
        assert item["released_at"] == "2023-01-15"
        assert item["short_description"] == "React debugging tools"
        assert item["categories"] == ["Developer Tools"]
        assert len(item["findings"]) == 1
        assert item["findings"][0]["finding_id"] == "malware_detected"
        assert item["findings"][0]["severity"] == "critical"
        assert "default" in item["governed_details"]
        assert item["brew_category_koi"] == "Command Line Tools & Utilities"
        assert item["browser_category_koi"] == "Developer Tools"
        assert item["chocolatey_category_koi"] == "Command Line Tools & Utilities"
        assert item["ide_category_koi"] == "Language Support & Tooling"
        assert item["software_category_koi"] == "Docs tools"

    @pytest.mark.parametrize(
        "args",
        [
            {"marketplace": "chrome_web_store"},
            {"item_id": "abc123"},
            {},
        ],
        ids=["missing_item_id", "missing_marketplace", "no_args"],
    )
    def test_inventory_item_get_missing_required_args(self, mock_client, args):
        """Test koi-inventory-item-get raises error for missing required arguments."""
        with pytest.raises(KeyError):
            koi_inventory_item_get_command(mock_client, args)


class TestClientGetInventoryItem:
    """Tests for the Client.get_inventory_item method."""

    def test_get_inventory_item_request(self, mock_client, inventory_item_response, mocker):
        """Test that get_inventory_item sends correct GET request with all required params."""
        mocker.patch.object(mock_client, "_http_request", return_value=inventory_item_response)

        result = mock_client.get_inventory_item(item_id="abc123", marketplace="chrome_web_store", version="2.0.0")

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "GET"
        assert call_kwargs["url_suffix"] == ApiPaths.inventory_item("abc123")
        assert call_kwargs["params"]["marketplace"] == "chrome_web_store"
        assert call_kwargs["params"]["version"] == "2.0.0"
        assert result == inventory_item_response

    def test_get_inventory_item_api_error(self, mock_client, mocker):
        """Test that get_inventory_item propagates API errors."""
        mocker.patch.object(
            mock_client,
            "_http_request",
            side_effect=DemistoException("Error in API call [404] - Not Found"),
        )

        with pytest.raises(DemistoException, match="Error in API call"):
            mock_client.get_inventory_item(item_id="nonexistent", marketplace="chrome_web_store", version="1.0.0")


# endregion

# region koi-inventory-search tests


class TestParseFilterFromArgs:
    """Tests for the parse_filter_from_args helper function."""

    def test_parse_inline_filter_json(self):
        """Test parsing a valid inline JSON filter string."""
        args = {"filter_json": '{"field": "risk_level", "operator": "eq", "value": "high"}'}
        result = parse_filter_from_args(args)

        assert result == {"field": "risk_level", "operator": "eq", "value": "high"}

    def test_parse_filter_from_file(self, mocker, tmp_path):
        """Test parsing a filter from a JSON file entry ID."""
        filter_data = {"field": "marketplace", "operator": "eq", "value": "vscode"}
        json_file = tmp_path / "filter.json"
        json_file.write_text(json.dumps(filter_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "filter.json"})

        args = {"filter_raw_json_entry_id": "entry-123"}
        result = parse_filter_from_args(args)

        assert result == filter_data

    def test_file_entry_takes_priority_over_inline(self, mocker, tmp_path):
        """Test that file entry ID takes priority over inline filter_json."""
        file_filter = {"source": "file"}
        json_file = tmp_path / "filter.json"
        json_file.write_text(json.dumps(file_filter))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "filter.json"})

        args = {
            "filter_raw_json_entry_id": "entry-123",
            "filter_json": '{"source": "inline"}',
        }
        result = parse_filter_from_args(args)

        assert result == file_filter

    def test_no_filter_raises_error(self):
        """Test that missing both filter sources raises DemistoException."""
        with pytest.raises(DemistoException, match="Either 'filter_json' or 'filter_raw_json_entry_id'"):
            parse_filter_from_args({})

    @pytest.mark.parametrize(
        "args, file_content, error_match",
        [
            ({"filter_json": "not valid json {{{"}, None, "Failed to parse filter_json"),
            ({"filter_json": json.dumps(["not", "a", "dict"])}, None, "expected a dictionary"),
            ({"filter_raw_json_entry_id": "entry-123"}, "not valid json {{{", "Failed to parse JSON filter file"),
            ({"filter_raw_json_entry_id": "entry-123"}, json.dumps(["not", "a", "dict"]), "expected a dictionary"),
        ],
        ids=["invalid_inline_json", "inline_not_a_dict", "invalid_file_json", "file_not_a_dict"],
    )
    def test_invalid_filter_content(self, mocker, tmp_path, args, file_content, error_match):
        """Test that invalid filter content raises DemistoException."""
        if file_content is not None:
            json_file = tmp_path / "filter.json"
            json_file.write_text(file_content)
            mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "filter.json"})

        with pytest.raises(DemistoException, match=error_match):
            parse_filter_from_args(args)

    @pytest.mark.parametrize(
        "mock_kwargs, error_match",
        [
            ({"side_effect": Exception("Entry not found")}, "Could not find file"),
            ({"return_value": {}}, "not a valid file entry"),
        ],
        ids=["entry_not_found", "entry_not_a_file"],
    )
    def test_file_entry_resolution_errors(self, mocker, mock_kwargs, error_match):
        """Test that entry resolution errors raise DemistoException."""
        mocker.patch.object(demisto, "getFilePath", **mock_kwargs)

        with pytest.raises(DemistoException, match=error_match):
            parse_filter_from_args({"filter_raw_json_entry_id": "entry-123"})


class TestKoiInventorySearchCommand:
    """Tests for the koi-inventory-search command."""

    @pytest.mark.parametrize(
        "args, expected_filter, expected_sort_by, expected_sort_direction",
        [
            (
                {"page": "1", "filter_json": '{"field": "risk_level", "operator": "eq", "value": "high"}'},
                {"field": "risk_level", "operator": "eq", "value": "high"},
                None,
                None,
            ),
            (
                {
                    "page": "1",
                    "filter_json": '{"field": "marketplace", "operator": "eq", "value": "vscode"}',
                    "sort_by": "risk_level",
                    "sort_direction": "desc",
                },
                {"field": "marketplace", "operator": "eq", "value": "vscode"},
                "risk_level",
                "desc",
            ),
        ],
        ids=["basic_filter", "filter_with_sorting"],
    )
    def test_inventory_search_single_page(
        self,
        mock_client,
        inventory_response,
        mocker,
        args,
        expected_filter,
        expected_sort_by,
        expected_sort_direction,
    ):
        """Test koi-inventory-search in single-page mode with various argument combinations."""
        mocker.patch.object(mock_client, "search_inventory", return_value=inventory_response)

        result = koi_inventory_search_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Inventory"
        assert result.outputs_key_field == "item_id"
        assert len(result.outputs) == 2
        mock_client.search_inventory.assert_called_once_with(
            page=1,
            page_size=Config.DEFAULT_PAGE_SIZE,
            filter_obj=expected_filter,
            sort_by=expected_sort_by,
            sort_direction=expected_sort_direction,
        )

    @pytest.mark.parametrize(
        "limit_arg, api_item_count, expected_output_count",
        [
            ("500", Config.MAX_PAGE_SIZE, 500),
            ("500", 0, 0),
            ("500", 50, 50),
            ("10", Config.MAX_PAGE_SIZE, 10),
        ],
        ids=[
            "full_page_satisfies_limit",
            "empty_response",
            "partial_page_stops_pagination",
            "trims_to_limit",
        ],
    )
    def test_inventory_search_auto_paginate_behavior(self, mock_client, mocker, limit_arg, api_item_count, expected_output_count):
        """Test auto-paginate behavior with various limit and API response combinations."""
        response = {
            "items": [{"item_id": f"item-{i}"} for i in range(api_item_count)],
            "total_count": api_item_count,
        }
        mocker.patch.object(mock_client, "search_inventory", return_value=response)

        args = {
            "limit": limit_arg,
            "filter_json": '{"field": "risk_level", "operator": "eq", "value": "high"}',
        }
        result = koi_inventory_search_command(mock_client, args)

        assert len(result.outputs) == expected_output_count

    def test_inventory_search_missing_filter_raises_error(self, mock_client):
        """Test that missing filter raises DemistoException."""
        with pytest.raises(DemistoException, match="Either 'filter_json' or 'filter_raw_json_entry_id'"):
            koi_inventory_search_command(mock_client, {"page": "1"})

    def test_inventory_search_from_file(self, mock_client, inventory_response, mocker, tmp_path):
        """Test koi-inventory-search with filter from file entry ID."""
        filter_data = {"field": "publisher_name", "operator": "contains", "value": "Meta"}
        json_file = tmp_path / "filter.json"
        json_file.write_text(json.dumps(filter_data))

        mocker.patch.object(demisto, "getFilePath", return_value={"path": str(json_file), "name": "filter.json"})
        mocker.patch.object(mock_client, "search_inventory", return_value=inventory_response)

        args = {"page": "1", "filter_raw_json_entry_id": "entry-123"}
        result = koi_inventory_search_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Inventory"
        mock_client.search_inventory.assert_called_once_with(
            page=1,
            page_size=Config.DEFAULT_PAGE_SIZE,
            filter_obj=filter_data,
            sort_by=None,
            sort_direction=None,
        )

    def test_inventory_search_outputs_and_readable(self, mock_client, inventory_response, mocker):
        """Test that all expected fields are present in outputs and readable output."""
        mocker.patch.object(mock_client, "search_inventory", return_value=inventory_response)

        args = {
            "page": "1",
            "filter_json": '{"field": "risk_level", "operator": "eq", "value": "high"}',
        }
        result = koi_inventory_search_command(mock_client, args)

        # Verify readable output contains key data
        assert "Inventory Search" in result.readable_output
        assert "React Developer Tools" in result.readable_output
        assert "Meta" in result.readable_output

        # Verify outputs structure
        assert len(result.outputs) == 2
        item = result.outputs[0]
        assert item["item_id"] == "abc123"
        assert item["item_display_name"] == "React Developer Tools"
        assert item["marketplace"] == "chrome_web_store"
        assert item["version"] == "1.0.0"
        assert item["platforms"] == ["chrome", "edge"]
        assert item["publisher_name"] == "Meta"
        assert item["risk"] == 5
        assert item["risk_level"] == "high"
        assert item["status"] == "APPROVED"
        assert item["endpoint_count"] == 42
        assert item["installs_count"] == 1000000
        assert item["installation_method"] == "marketplace"
        assert item["is_first_party"] is False
        assert item["is_signed"] is True
        assert item["first_seen"] == "2024-01-01T10:00:00Z"
        assert item["last_seen"] == "2024-10-15T10:00:00Z"
        assert item["last_used"] == "2025-06-15T10:00:00Z"
        assert item["released_at"] == "2023-01-15"
        assert item["short_description"] == "React debugging tools"
        assert item["categories"] == ["Developer Tools"]
        assert item["findings"] == ["malware", "permissions"]
        assert "group-uuid-123" in item["governed_details"]
        assert item["governed_details"]["group-uuid-123"]["action"] == "allow"
        assert item["brew_category_koi"] == "Command Line Tools & Utilities"
        assert item["browser_category_koi"] == "Developer Tools"
        assert item["chocolatey_category_koi"] == "Command Line Tools & Utilities"
        assert item["ide_category_koi"] == "Language Support & Tooling"
        assert item["software_category_koi"] == "Docs tools"


class TestClientSearchInventory:
    """Tests for the Client.search_inventory method."""

    @pytest.mark.parametrize(
        "sort_by, sort_direction, expect_sort_in_body",
        [
            ("first_seen", "desc", True),
            (None, None, False),
        ],
        ids=["with_sorting", "without_sorting"],
    )
    def test_search_inventory_request(
        self, mock_client, inventory_response, mocker, sort_by, sort_direction, expect_sort_in_body
    ):
        """Test that search_inventory sends correct POST request with and without sorting."""
        mocker.patch.object(mock_client, "_http_request", return_value=inventory_response)

        filter_obj = {"field": "risk_level", "operator": "eq", "value": "high"}
        result = mock_client.search_inventory(
            page=1, page_size=100, filter_obj=filter_obj, sort_by=sort_by, sort_direction=sort_direction
        )

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "POST"
        assert call_kwargs["url_suffix"] == ApiPaths.INVENTORY_SEARCH
        body = call_kwargs["json_data"]
        assert body["page"] == 1
        assert body["page_size"] == 100
        assert body["filter"] == filter_obj
        if expect_sort_in_body:
            assert body["sort_by"] == sort_by
            assert body["sort_direction"] == sort_direction
        else:
            assert "sort_by" not in body
            assert "sort_direction" not in body
        assert result == inventory_response

    def test_inventory_search_page_size_exceeds_max_raises_error(self, mock_client, mocker):
        """Test that page_size exceeding MAX_PAGE_SIZE raises ValueError."""
        args = {"page": "1", "page_size": "501", "filter_json": '{"field": "test"}'}
        with pytest.raises(DemistoException, match="page_size .* exceeds the maximum allowed value"):
            koi_inventory_search_command(mock_client, args)

    def test_inventory_search_limit_exceeds_max_raises_error(self, mock_client, mocker):
        """Test that limit exceeding MAX_LIMIT raises ValueError."""
        args = {"limit": "1001", "filter_json": '{"field": "test"}'}
        with pytest.raises(DemistoException, match="limit .* exceeds the maximum allowed value"):
            koi_inventory_search_command(mock_client, args)

    def test_search_inventory_api_error(self, mock_client, mocker):
        """Test that search_inventory propagates API errors."""
        mocker.patch.object(
            mock_client,
            "_http_request",
            side_effect=DemistoException("Error in API call [400] - Bad Request"),
        )

        with pytest.raises(DemistoException, match="Error in API call"):
            mock_client.search_inventory(page=1, page_size=100, filter_obj={"field": "test"})


# endregion

# region koi-inventory-item-endpoints-list tests


class TestKoiInventoryItemEndpointsListCommand:
    """Tests for the koi-inventory-item-endpoints-list command."""

    @pytest.mark.parametrize(
        "args, expected_version, expected_page, expected_page_size",
        [
            (
                {"item_id": "abc123", "marketplace": "chrome_web_store", "version": "1.0.0", "page": "1"},
                "1.0.0",
                1,
                Config.DEFAULT_PAGE_SIZE,
            ),
            (
                {"item_id": "abc123", "marketplace": "chrome_web_store", "version": "2.0.0", "page": "2", "page_size": "50"},
                "2.0.0",
                2,
                50,
            ),
            (
                {
                    "item_id": "abc123",
                    "marketplace": "chrome_web_store",
                    "version": "1.0.0",
                    "page": "3",
                    "page_size": "25",
                    "limit": "200",
                },
                "1.0.0",
                3,
                25,
            ),
        ],
        ids=["version_1_default_page_size", "explicit_version_and_page_size", "limit_ignored_when_page_provided"],
    )
    def test_endpoints_list_single_page(
        self,
        mock_client,
        inventory_item_endpoints_response,
        mocker,
        args,
        expected_version,
        expected_page,
        expected_page_size,
    ):
        """Test koi-inventory-item-endpoints-list in single-page mode with various argument combinations."""
        mocker.patch.object(mock_client, "get_inventory_item_endpoints", return_value=inventory_item_endpoints_response)

        result = koi_inventory_item_endpoints_list_command(mock_client, args)

        assert result.outputs_prefix == "Koi.Inventory.Endpoint"
        assert result.outputs_key_field == "id"
        assert len(result.outputs) == 2
        mock_client.get_inventory_item_endpoints.assert_called_once_with(
            item_id="abc123",
            marketplace="chrome_web_store",
            version=expected_version,
            page=expected_page,
            page_size=expected_page_size,
        )

    @pytest.mark.parametrize(
        "limit_arg, api_endpoint_count, expected_output_count",
        [
            ("500", Config.MAX_PAGE_SIZE, 500),
            ("500", 0, 0),
            ("500", 50, 50),
            ("10", Config.MAX_PAGE_SIZE, 10),
        ],
        ids=[
            "full_page_satisfies_limit",
            "empty_response",
            "partial_page_stops_pagination",
            "trims_to_limit",
        ],
    )
    def test_endpoints_list_auto_paginate_behavior(
        self, mock_client, mocker, limit_arg, api_endpoint_count, expected_output_count
    ):
        """Test auto-paginate behavior with various limit and API response combinations."""
        response = {
            "endpoints": [{"id": f"device-{i}"} for i in range(api_endpoint_count)],
            "total_count": api_endpoint_count,
        }
        mocker.patch.object(mock_client, "get_inventory_item_endpoints", return_value=response)

        args = {"item_id": "abc123", "marketplace": "chrome_web_store", "version": "1.0.0", "limit": limit_arg}
        result = koi_inventory_item_endpoints_list_command(mock_client, args)

        assert len(result.outputs) == expected_output_count

    def test_endpoints_list_outputs_and_readable(self, mock_client, inventory_item_endpoints_response, mocker):
        """Test that all expected fields are present in outputs and readable output."""
        mocker.patch.object(mock_client, "get_inventory_item_endpoints", return_value=inventory_item_endpoints_response)

        args = {"item_id": "abc123", "marketplace": "chrome_web_store", "version": "1.0.0", "page": "1"}
        result = koi_inventory_item_endpoints_list_command(mock_client, args)

        # Verify readable output
        assert "Inventory Item Endpoints" in result.readable_output
        assert "laptop-01" in result.readable_output
        assert "john.doe" in result.readable_output

        # Verify all fields in outputs
        assert len(result.outputs) == 2
        endpoint = result.outputs[0]
        assert endpoint["id"] == "device-123"
        assert endpoint["hostname"] == "laptop-01"
        assert endpoint["os"] == "windows"
        assert endpoint["platform"] == "chrome"
        assert endpoint["serial"] == "ABC123XYZ"
        assert endpoint["last_logged_on_user"] == "john.doe"
        assert endpoint["activation_status"] == "enabled"
        assert endpoint["path"] == "/Applications/Google Chrome.app/Contents/Extensions/abc123"
        assert endpoint["first_seen"] == "2024-01-01T10:00:00Z"
        assert endpoint["last_seen"] == "2024-10-15T10:00:00Z"

    @pytest.mark.parametrize(
        "args",
        [
            {"marketplace": "chrome_web_store"},
            {"item_id": "abc123"},
            {},
        ],
        ids=["missing_item_id", "missing_marketplace", "no_args"],
    )
    def test_endpoints_list_missing_required_args(self, mock_client, args):
        """Test that missing required arguments raises KeyError."""
        with pytest.raises(KeyError):
            koi_inventory_item_endpoints_list_command(mock_client, args)


class TestClientGetInventoryItemEndpoints:
    """Tests for the Client.get_inventory_item_endpoints method."""

    def test_get_inventory_item_endpoints_request(self, mock_client, inventory_item_endpoints_response, mocker):
        """Test that get_inventory_item_endpoints sends correct GET request."""
        mocker.patch.object(mock_client, "_http_request", return_value=inventory_item_endpoints_response)

        result = mock_client.get_inventory_item_endpoints(
            item_id="abc123", marketplace="chrome_web_store", version="1.0.0", page=1, page_size=100
        )

        call_kwargs = mock_client._http_request.call_args[1]
        assert call_kwargs["method"] == "GET"
        assert call_kwargs["url_suffix"] == ApiPaths.inventory_item_endpoints("abc123")
        assert call_kwargs["params"]["marketplace"] == "chrome_web_store"
        assert call_kwargs["params"]["version"] == "1.0.0"
        assert call_kwargs["params"]["page"] == 1
        assert call_kwargs["params"]["page_size"] == 100
        assert result == inventory_item_endpoints_response

    def test_inventory_item_endpoints_page_size_exceeds_max_raises_error(self, mock_client, mocker):
        """Test that page_size exceeding MAX_PAGE_SIZE raises ValueError."""
        args = {"item_id": "abc123", "marketplace": "chrome_web_store", "version": "1.0.0", "page": "1", "page_size": "501"}
        with pytest.raises(DemistoException, match="page_size .* exceeds the maximum allowed value"):
            koi_inventory_item_endpoints_list_command(mock_client, args)

    def test_inventory_item_endpoints_limit_exceeds_max_raises_error(self, mock_client, mocker):
        """Test that limit exceeding MAX_LIMIT raises ValueError."""
        args = {"item_id": "abc123", "marketplace": "chrome_web_store", "version": "1.0.0", "limit": "1001"}
        with pytest.raises(DemistoException, match="limit .* exceeds the maximum allowed value"):
            koi_inventory_item_endpoints_list_command(mock_client, args)

    def test_get_inventory_item_endpoints_api_error(self, mock_client, mocker):
        """Test that get_inventory_item_endpoints propagates API errors."""
        mocker.patch.object(
            mock_client,
            "_http_request",
            side_effect=DemistoException("Error in API call [404] - Not Found"),
        )

        with pytest.raises(DemistoException, match="Error in API call"):
            mock_client.get_inventory_item_endpoints(
                item_id="nonexistent", marketplace="chrome_web_store", version="1.0.0", page=1, page_size=100
            )


# endregion

# region Empty log_types guard tests


class TestEmptyLogTypesGuard:
    """Tests for the empty log_types guard in fetch_events_command."""

    def test_empty_event_types_to_fetch_does_not_crash(self, mock_client, mocker):
        """Test that an empty event_types_to_fetch param does not crash on ThreadPoolExecutor(max_workers=0)."""
        mocker.patch.object(
            demisto,
            "params",
            return_value={"max_fetch": "5000", "event_types_to_fetch": ""},
        )
        existing_last_run = {"last_fetch_alerts": "2024-01-01T00:00:00Z"}
        mocker.patch.object(demisto, "getLastRun", return_value=existing_last_run)
        mock_send = mocker.patch.object(mock_client, "send_events")
        mock_set_last_run = mocker.patch.object(demisto, "setLastRun")

        # Should return cleanly without raising
        fetch_events_command(mock_client)

        # No events sent
        mock_send.assert_not_called()
        # last_run preserved as-is
        mock_set_last_run.assert_called_once_with(existing_last_run)


# endregion

# region Client.send_events tests


class TestClientSendEvents:
    """Tests for the Client.send_events method."""

    def test_send_events_calls_send_events_to_xsiam(self, mock_client, mocker):
        """Test that send_events delegates to send_events_to_xsiam with correct vendor/product."""
        mock_send_to_xsiam = mocker.patch("Koi.send_events_to_xsiam")
        events = [{"id": "1", "_time": "2024-01-01T00:00:00Z"}, {"id": "2", "_time": "2024-01-01T00:00:01Z"}]

        mock_client.send_events(events)

        mock_send_to_xsiam.assert_called_once_with(events=events, vendor=Config.VENDOR, product=Config.PRODUCT)

    def test_send_events_with_empty_list(self, mock_client, mocker):
        """Test that send_events still calls send_events_to_xsiam when events list is empty."""
        mock_send_to_xsiam = mocker.patch("Koi.send_events_to_xsiam")

        mock_client.send_events([])

        mock_send_to_xsiam.assert_called_once_with(events=[], vendor=Config.VENDOR, product=Config.PRODUCT)


# endregion