CapeSandbox

CAPE Sandbox is an open-source software for automating the analysis of suspicious files and URLs.

Forensics & Malware Analysis · Cape Sandbox

Details

IDCapeSandbox
ProviderOpen Source
CategoryForensics & Malware Analysis
From Version6.8.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

Overview

CAPE Sandbox is an open-source software for automating the analysis of suspicious files and URLs. To learn more about CAPE Sandbox, visit the official CAPE documentation.

This integration was integrated and tested with CAPE V2 API.

Authorization

This integration supports two authentication methods:

  • API Token - Recommended. Generate a token in your CAPE Sandbox instance.
  • Username and Password - The integration will automatically generate authentication tokens.

Configure Cape Sandbox in Cortex

Parameter Description Required
Server URL Base URL of the CAPE Sandbox. True
API token Token value as generated in CAPE. If provided, Username/Password is not required. False
Username Required if ‘Username and Password’ is selected above.
Provides credentials for token generation.
False
Password Password for authentication. Required if using Username/Password authentication method. False
Trust any certificate (not secure)   False
Use system proxy settings   False

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.

cape-file-submit


Submits a file for analysis to CAPE Sandbox. This command supports PCAP files and automatically sets the pcap option to 1. The command polls the CAPE server until the analysis task is complete, and then returns the task results.

Note: Analysis scans may take a long time to complete and could cause a timeout. If a timeout occurs, use the !cape-tasks-list task_id=<task_id> command to check the status of your task.

Base Command

cape-file-submit

Input

Argument Name Description Required
entry_id The War Room entry ID of the file to submit for analysis. Required
package The analysis package to use (e.g., ps1 for PowerShell scripts, exe for executables, dll for DLL files). For available packages, see the CAPE documentation. Optional
timeout The maximum time in seconds to allow for the analysis to complete. Optional
priority The priority level to assign to the task (1-3, where 1 is highest priority). Optional
options A string of additional options to pass to the analysis (e.g., options:function=DllMain). For available options, see the CAPE documentation. Optional
machine The specific ID of the analysis machine to use for the task. Optional
platform The name of the platform to select the analysis machine from (e.g., windows). Optional
tags Comma-separated tags to associate with the analysis task. Optional
custom A custom string to pass to the analysis. Optional
memory Set to true to enable a full memory dump during analysis. Possible values are: true, false. Optional
enforce_timeout Set to true to strictly enforce the analysis timeout. Possible values are: true, false. Optional
clock The VM clock time in the format %m-%d-%Y %H:%M:%S. Optional

Context Output

Path Description
Cape.Task Task data returned from CAPE when analysis is ready. This object contains details about the submitted task and its status.

cape-file-view


View detailed information about a file that has been analyzed by CAPE Sandbox. You must specify at least one identifier: Task ID, MD5 hash, or SHA256 hash.

Base Command

cape-file-view

Input

Argument Name Description Required
task_id The unique identifier of the analysis task to view. Optional
md5 The MD5 hash of the file to view. Optional
sha256 The SHA256 hash of the file to view. Optional

Context Output

Path Description
Cape.File.id The ID of the file or task.
Cape.File.file_type The detected type of the file.
Cape.File.md5 The MD5 hash of the file.
Cape.File.crc32 The CRC32 checksum of the file.
Cape.File.sha256 The SHA256 hash of the file.
Cape.File.sha512 The SHA512 hash of the file.
Cape.File.parent The parent process or source of the file.
Cape.File.source_url The URL from which the file was obtained, if applicable.

cape-sample-download


Download a sample file from a CAPE Sandbox task. You must specify at least one identifier: Task ID, MD5 hash, SHA1 hash, or SHA256 hash. The downloaded file will be added to the War Room.

Base Command

cape-sample-download

Input

Argument Name Description Required
task_id The unique identifier of the analysis task to download the sample from. Optional
md5 The MD5 hash of the sample file to download. Optional
sha1 The SHA1 hash of the sample file to download. Optional
sha256 The SHA256 hash of the sample file to download. Optional

Context Output

There is no context output for this command. The command directly downloads the file.

cape-url-submit


Submit a URL for analysis to CAPE Sandbox. The command polls the CAPE server until the analysis task is reported as complete, and then returns the task view with the analysis results.

Note: Analysis scans may take a long time to complete and could cause a timeout. If a timeout occurs, use the !cape-tasks-list task_id=<task_id> command to check the status of your task.

Base Command

cape-url-submit

Input

Argument Name Description Required
url The URL to submit for analysis. Required
package The analysis package to use for URL analysis (e.g., ie for Internet Explorer, chrome for Chrome browser). For available packages, see the CAPE documentation. Optional
timeout The maximum time in seconds to allow for the analysis to complete. Optional
priority The priority level to assign to the task (1-3, where 1 is highest priority). Optional
options A string of additional options to pass to the analysis (e.g., options:function=DllMain). For available options, see the CAPE documentation. Optional
machine The specific ID of the analysis machine to use for the task. Optional
platform The name of the platform to select the analysis machine from (e.g., windows). Optional
tags Comma-separated tags to associate with the analysis task. Optional
custom A custom string to pass to the analysis. Optional
memory Set to true to enable a full memory dump during analysis. Possible values are: true, false. Optional
enforce_timeout Set to true to strictly enforce the analysis timeout. Possible values are: true, false. Optional
clock The VM clock time in the format %m-%d-%Y %H:%M:%S. Optional
polling Enable or disable generic polling for the CAPE task. Possible values are: true, false. Default is true. Optional
pollingInterval The polling interval in seconds. It is strongly recommended to use an interval of 60 seconds or more to prevent API throttling by the CAPE server. Optional
pollingTimeout The total time allowed in seconds for the XSOAR/XSIAM polling sequence to complete. Optional

Context Output

Path Description
Cape.Task Task data returned from CAPE when analysis is ready. This object contains details about the submitted task and its status.

cape-task-delete


Delete a CAPE Sandbox analysis task by its Task ID.

Base Command

cape-task-delete

Input

Argument Name Description Required
task_id The unique identifier of the task to delete. Required

Context Output

There is no context output for this command.

cape-tasks-list


Returns a list of CAPE Sandbox tasks. If a task_id is provided, the command returns the detailed information for that specific task.

Base Command

cape-tasks-list

Input

Argument Name Description Required
task_id The unique identifier of the task to retrieve. If provided, returns details for that specific task only. Optional
page The page number for pagination (starts at 1). Optional
page_size The number of tasks to return per page. Maximum is 50. Default is 50. Optional

Context Output

Path Description
Cape.Task Task data, including details for a single task if task_id was provided, or a list of tasks.

cape-task-report-get


Retrieve the analysis report associated with a specified CAPE Sandbox task ID. If zip=true, the report will be returned as a downloadable ZIP file.

Base Command

cape-task-report-get

Input

Argument Name Description Required
task_id The unique identifier of the task for which to retrieve the report. Required
format The desired format of the report. Possible values are: json (full JSON report), maec (MAEC 4.1 format), maec5 (MAEC 5.0 format), metadata (metadata only), lite (lightweight report), all (all available formats). Default is json. Optional
zip Set to true to download the report as a ZIP file. Possible values are: true, false. Default is false. Optional

Context Output

Path Description
Cape.Task.Report Details from the CAPE analysis report info object. This will vary based on the report format requested.

cape-pcap-file-download


Download the PCAP (Packet Capture) network dump file associated with a specific CAPE Sandbox task ID. The PCAP file contains all network traffic captured during the analysis and can be analyzed with tools like Wireshark.

Base Command

cape-pcap-file-download

Input

Argument Name Description Required
task_id The unique identifier of the task for which to download the PCAP file. Required

Context Output

There is no context output for this command. The command directly downloads the PCAP file.

cape-task-screenshot-download


Download screenshots captured during the analysis of a CAPE Sandbox task. Screenshots show the visual behavior of the analyzed file or URL. If a specific screenshot number is provided, only that single screenshot will be downloaded; otherwise, all available screenshots for the task will be downloaded as a ZIP file.

Base Command

cape-task-screenshot-download

Input

Argument Name Description Required
task_id The unique identifier of the task for which to download screenshots. Required
screenshot The specific screenshot number to download (e.g., 1, 2). If not provided, all screenshots are downloaded. Optional

Context Output

There is no context output for this command. The command directly downloads the screenshot files.

cape-machines-list


Returns a list of available analysis machines configured in CAPE Sandbox. If a machine_name is provided, the command returns detailed information for that specific machine.

Base Command

cape-machines-list

Input

Argument Name Description Required
machine_name The name of the machine to fetch details for. If provided, returns details for that specific machine only. Optional
all_results Set to true to return all machines, ignoring the limit parameter. Possible values are: true, false. Default is false. Optional
limit The maximum number of machines to return when listing. Default is 50. Optional

Context Output

Path Description
Cape.Machine Machine data, including details for a single machine if machine_name was provided, or a list of machines.

cape-cuckoo-status-get


Returns the overall status of the CAPE/Cuckoo system in a human-readable format. This command provides a quick overview of the sandbox’s operational status.

Base Command

cape-cuckoo-status-get

Input

There are no input arguments for this command.

Context Output

There is no context output for this command. The output is human-readable text directly in the War Room.

Configuration parameters

  • url — Server URL (required)
  • token_credentials — API Token
  • credentials — Username
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (12)

  • cape-cuckoo-status-get

    Returns the overall status of the CAPE/Cuckoo system in a human-readable format. This command provides a quick overview of the sandbox's operational status.

  • cape-file-submit

    Submits a file to the CAPE Sandbox for analysis. This command supports PCAP files and automatically sets the `pcap` parameter to `1`. The command polls the CAPE server until the analysis task is complete, and then returns the task results. Note: Analysis scans may take a long time to complete and could cause a timeout. If a timeout occurs, use the !cape-tasks-list task_id=<task_id> command to check the status of your task.

  • cape-file-view

    View detailed information about a file that has been analyzed by CAPE Sandbox. You must specify at least one identifier - Task ID, MD5 hash, or SHA256 hash.

  • cape-machines-list

    Returns a list of available analysis machines configured in CAPE Sandbox. If a machine_name is provided, the command returns detailed information for that specific machine.

  • cape-pcap-file-download

    Download the PCAP (Packet Capture) network dump file associated with a specific CAPE Sandbox task ID. The PCAP file contains all network traffic captured during the analysis and can be analyzed with tools like Wireshark.

  • cape-sample-download

    Download a sample file from a CAPE Sandbox task. You must specify at least one identifier - Task ID, MD5 hash, SHA1 hash, or SHA256 hash. The downloaded file will be added to the War Room.

  • cape-task-delete

    Delete a CAPE Sandbox analysis task by its Task ID.

  • cape-task-poll

    Internal command used by cape-file-submit and cape-url-submit for polling status.

  • cape-task-report-get

    Retrieve the analysis report associated with a specified CAPE Sandbox task ID. If zip=true, the report will be returned as a downloadable ZIP file.

  • cape-task-screenshot-download

    Download screenshots captured during the analysis of a CAPE Sandbox task. Screenshots show the visual behavior of the analyzed file or URL. If a specific screenshot number is provided, only that single screenshot will be downloaded; otherwise, all available screenshots for the task will be downloaded.

  • cape-tasks-list

    Returns a list of CAPE Sandbox tasks. If a task_id is provided, the command returns the detailed information for that specific task.

  • cape-url-submit

    Submits a URL to the CAPE Sandbox for analysis. The command polls the CAPE server until the analysis task is complete, and then returns the analysis results. Note: Analysis scans may take a long time to complete and could cause a timeout. If a timeout occurs, use the !cape-tasks-list task_id=<task_id> command to check the status of your task.

import json
import os
import sys
import time
import types

import pytest
from CommonServerPython import *

# Mock DemistoClassApiModule before CapeSandbox is imported
mock_demisto_class_api_module = types.ModuleType("DemistoClassApiModule")
sys.modules["DemistoClassApiModule"] = mock_demisto_class_api_module

# Import CapeSandbox after mock setup - this is intentional and required
import CapeSandbox  # noqa: E402
from CapeSandbox import (  # noqa: E402
    FILE_TYPE_FILE,
    FILE_TYPE_NETWORK_DUMP,
    FILE_TYPE_REPORT,
    FILE_TYPE_SCREENSHOT,
    CapeSandboxClient,
    build_file_name,
    build_submit_form,
    cape_cuckoo_status_get_command,
    cape_file_submit_command,
    cape_file_view_command,
    cape_machines_list_command,
    cape_pcap_file_download_command,
    cape_sample_file_download_command,
    cape_task_delete_command,
    cape_task_report_get_command,
    cape_task_screenshot_download_command,
    cape_tasks_list_command,
    cape_url_submit_command,
    extract_entry_file_data,
    is_valid_md5,
    is_valid_sha1,
    is_valid_sha256,
    parse_integration_params,
)

# ========================================
# Constants
# ========================================

SERVER_URL = "http://test.example.com"
TEST_DATA_PATH_SUFFIX = "test_data"
INTEGRATION_DIR_REL = "Packs/CapeSandbox/Integrations/CapeSandbox/"

MOCK_MD5 = "00112233445566778899aabbccddeeff"
MOCK_SHA1 = "0011223344556677889900112233445566778899"
MOCK_SHA256 = "0011223344556677889900112233445566778899001122334455667788990011"


# ========================================
# Helper Functions
# ========================================


def get_full_path_unified(file_name):
    """Calculates the full path for a file in the test_data folder."""
    path = os.path.join(os.path.dirname(__file__), TEST_DATA_PATH_SUFFIX, file_name)

    if not os.path.exists(path):
        fallback_path = os.path.join(os.getcwd(), INTEGRATION_DIR_REL, TEST_DATA_PATH_SUFFIX, file_name)
        if os.path.exists(fallback_path):
            path = fallback_path

    if not os.path.exists(path):
        raise FileNotFoundError(f"Mock file not found: {file_name} in {TEST_DATA_PATH_SUFFIX}.")

    return path


def util_load_json(file_name):
    """Loads a JSON file from the test_data directory."""
    path = get_full_path_unified(file_name)
    with open(path, encoding="utf-8") as f:
        return json.loads(f.read())


def util_load_file(file_name):
    """Loads a binary file from the test_data directory."""
    path = get_full_path_unified(file_name)
    with open(path, "rb") as f:
        return f.read()


def compare_string_ignore_case(actual, expected):
    """Compares two strings ignoring case."""
    if actual is None or expected is None:
        return actual == expected
    return str(actual).lower() == str(expected).lower()


# ========================================
# Fixtures
# ========================================


@pytest.fixture()
def client():
    """Returns a CapeSandboxClient instance for testing."""
    return CapeSandboxClient(
        base_url=SERVER_URL,
        verify=True,
        proxy=True,
        api_token="MOCK_API_TOKEN_FOR_TESTING",
        username=None,
        password=None,
    )


@pytest.fixture
def mock_context():
    """Fixture to ensure integration context is initialized and cleaned up."""
    set_integration_context({})
    yield
    set_integration_context({})


# ========================================
# Tests: Helper Functions
# ========================================


@pytest.mark.parametrize(
    "params,expected_error",
    [
        ({"url": ""}, "Server URL.+is required"),
        ({}, "Server URL.+is required"),
    ],
)
def test_parse_integration_params_missing_url_fail(params, expected_error):
    """Tests parse_integration_params fails if 'url' is missing or empty."""
    with pytest.raises(DemistoException, match=expected_error):
        parse_integration_params(params)


@pytest.mark.parametrize(
    "params,expected_api_token,expected_username,expected_verify,expected_proxy",
    [
        # API token takes precedence over username/password
        (
            {
                "url": SERVER_URL,
                "api_token": "API_TOKEN",
                "username": "user",
                "password": "pwd",
                "insecure": True,
                "proxy": True,
                "credentials": {"identifier": "user_cred", "password": "pwd_cred"},
            },
            "API_TOKEN",
            "user_cred",
            False,
            True,
        ),
        # token_credentials takes precedence over api_token
        (
            {
                "url": SERVER_URL,
                "token_credentials": {"password": "TOKEN_FROM_CREDS"},
                "api_token": "API_TOKEN_DIRECT",
                "insecure": False,
                "proxy": False,
            },
            "TOKEN_FROM_CREDS",
            None,
            True,
            False,
        ),
        # URL with trailing slash is stripped
        (
            {"url": f"{SERVER_URL}/", "api_token": "TOKEN"},
            "TOKEN",
            None,
            True,
            False,
        ),
        # credentials identifier takes precedence over username
        (
            {
                "url": SERVER_URL,
                "credentials": {"identifier": "cred_user", "password": "cred_pwd"},
                "username": "direct_user",
                "password": "direct_pwd",
            },
            None,
            "cred_user",
            True,
            False,
        ),
    ],
)
def test_parse_integration_params_various_configs(params, expected_api_token, expected_username, expected_verify, expected_proxy):
    """Tests parse_integration_params handles various configuration scenarios."""
    result = parse_integration_params(params)
    assert result["api_token"] == expected_api_token
    assert result["username"] == expected_username
    assert result["verify_certificate"] == expected_verify
    assert result["proxy"] == expected_proxy
    assert result["base_url"] == SERVER_URL


def test_build_submit_form_full_and_url_mode():
    """Tests build_submit_form correctly assigns all optional args and handles URL mode."""
    args = {
        "url": "http://test.example.com",
        "package": "win_exe",
        "timeout": 120,
        "priority": 2,
        "memory": "True",
        "enforce_timeout": "false",
        "tags": "test_tag",
    }

    form_url = build_submit_form(args, url_mode=True)
    assert form_url["url"] == "http://test.example.com"
    assert form_url["package"] == "win_exe"
    assert form_url["timeout"] == 120
    assert form_url["priority"] == 2
    assert form_url["memory"] == "1"
    assert "enforce_timeout" not in form_url

    form_file = build_submit_form(args, url_mode=False)
    assert "url" not in form_file
    assert "memory" in form_file


@pytest.mark.parametrize(
    "args,url_mode,check_keys_present,check_keys_absent",
    [
        # All optional parameters with URL mode
        (
            {
                "url": "http://test.example.com",
                "package": "win_exe",
                "timeout": 120,
                "priority": 2,
                "memory": "True",
                "enforce_timeout": "True",
                "tags": "test_tag",
                "options": "opt1,opt2",
                "machine": "win10",
                "platform": "windows",
                "custom": "custom_data",
                "clock": "1970-01-01 12:00:00",
            },
            True,
            [
                "url",
                "package",
                "timeout",
                "priority",
                "memory",
                "enforce_timeout",
                "tags",
                "options",
                "machine",
                "platform",
                "custom",
                "clock",
            ],
            [],
        ),
        # File mode excludes URL
        (
            {"package": "win_exe", "memory": "True", "url": "http://test.example.com"},
            False,
            ["package", "memory"],
            ["url"],
        ),
        # Empty args
        ({}, False, [], []),
        # Boolean false values excluded
        (
            {"memory": "false", "enforce_timeout": "False", "package": "exe"},
            False,
            ["package"],
            ["memory", "enforce_timeout"],
        ),
    ],
)
def test_build_submit_form_all_parameters(args, url_mode, check_keys_present, check_keys_absent):
    """Tests build_submit_form with comprehensive parameter combinations."""
    form = build_submit_form(args, url_mode=url_mode)

    for key in check_keys_present:
        assert key in form, f"Expected key '{key}' not in form"

    for key in check_keys_absent:
        assert key not in form, f"Unexpected key '{key}' found in form"


@pytest.mark.parametrize(
    "identifier,file_type_info,file_format,screenshot_number,expected",
    [
        (101, FILE_TYPE_SCREENSHOT, None, 5, "cape_task_101_screenshot_5.png"),
        (102, FILE_TYPE_REPORT, "csv", None, "cape_task_102_report.csv"),
        (MOCK_MD5, FILE_TYPE_FILE, None, None, f"cape_task_{MOCK_MD5}_file.json"),
        (103, FILE_TYPE_NETWORK_DUMP, None, None, "cape_task_103_network_dump.pcap"),
        ("12345", None, "txt", None, "cape_task_12345.txt"),
        ("404", None, None, None, "cape_task_404.dat"),
    ],
)
def test_build_file_name_all_types(identifier, file_type_info, file_format, screenshot_number, expected):
    """Tests build_file_name constructs filenames for all supported types."""
    result = build_file_name(
        identifier,
        file_type_info=file_type_info,
        file_format=file_format,
        screenshot_number=screenshot_number,
    )
    assert result == expected


@pytest.mark.parametrize(
    "identifier,file_type_info,file_format,expected",
    [
        # Report with different formats
        (101, FILE_TYPE_REPORT, "pdf", "cape_task_101_report.pdf"),
        (102, FILE_TYPE_REPORT, "html", "cape_task_102_report.html"),
        (103, FILE_TYPE_REPORT, "zip", "cape_task_103_report.zip"),
        # File with different formats
        (201, FILE_TYPE_FILE, "bin", "cape_task_201_file.bin"),
        (202, FILE_TYPE_FILE, "exe", "cape_task_202_file.exe"),
    ],
)
def test_build_file_name_report_formats(identifier, file_type_info, file_format, expected):
    """Tests build_file_name with various report and file formats."""
    result = build_file_name(identifier, file_type_info=file_type_info, file_format=file_format)
    assert result == expected


@pytest.mark.parametrize(
    "status,expected",
    [
        ("reported", True),
        ("running", False),
        ("pending", False),
        ("completed", False),
        ("", False),
        ("REPORTED", False),
    ],
)
def test_status_is_reported(status, expected):
    """Tests status_is_reported function correctly identifies reported status."""
    assert CapeSandbox.status_is_reported(status) is expected


def test_extract_entry_file_data_not_found_fail(mocker):
    """Tests extract_entry_file_data raises DemistoException on missing entry."""
    mocker.patch.object(demisto, "getFilePath", return_value={})
    with pytest.raises(DemistoException, match="Could not find file or entry"):
        extract_entry_file_data("not_an_entry")


def test_extract_entry_file_data_general_exception_fail(mocker):
    """Tests extract_entry_file_data handles generic exceptions."""
    mocker.patch.object(demisto, "getFilePath", side_effect=Exception("API error"))
    with pytest.raises(DemistoException, match="An unexpected error occurred"):
        extract_entry_file_data("entry_id")


def test_extract_entry_file_data_uses_name_field(mocker):
    """Tests extract_entry_file_data uses 'name' key when available."""
    mocker.patch.object(
        demisto,
        "getFilePath",
        return_value={"path": "/tmp/a.txt", "name": "custom.txt"},
    )
    path, name = extract_entry_file_data("entry_id")
    assert name == "custom.txt"


def test_extract_entry_file_data_uses_basename_fallback(mocker):
    """Tests extract_entry_file_data uses os.path.basename if 'name' is missing."""
    mocker.patch.object(demisto, "getFilePath", return_value={"path": "/tmp/file.exe"})
    path, name = extract_entry_file_data("entry_id")
    assert name == "file.exe"


@pytest.mark.parametrize(
    "validator_func,valid_hash,invalid_hash",
    [
        (is_valid_md5, MOCK_MD5, "BADHASH"),
        (is_valid_sha1, MOCK_SHA1, MOCK_MD5),
        (is_valid_sha256, MOCK_SHA256, MOCK_MD5),
    ],
)
def test_hash_validators(validator_func, valid_hash, invalid_hash):
    """Tests all hash format validation functions."""
    assert validator_func(valid_hash)
    assert not validator_func(invalid_hash)
    assert not validator_func(None)


# ========================================
# Tests: Client Initialization & Authentication
# ========================================


@pytest.mark.parametrize(
    "api_token,username,password",
    [
        (None, None, None),
        (None, "user", None),
    ],
)
def test_client_init_auth_fail(api_token, username, password):
    """Tests client initialization fails if auth is missing or incomplete."""
    with pytest.raises(
        DemistoException,
        match=r"Either API token or Username \+ Password must be provided",
    ):
        CapeSandboxClient(
            base_url=SERVER_URL,
            verify=True,
            proxy=True,
            api_token=api_token,
            username=username,
            password=password,
        )


def test_ensure_token_uses_api_token(client):
    """Tests ensure_token returns api_token when available."""
    assert client.ensure_token() == "MOCK_API_TOKEN_FOR_TESTING"


def test_ensure_token_uses_cached_token(mocker, mock_context):
    """Tests ensure_token uses valid token from cache."""
    mock_time = int(time.time()) + 3600
    set_integration_context({"auth_info": {"token": "CACHED_TOKEN", "valid_until": str(mock_time)}})

    client_auth = CapeSandboxClient(
        base_url=SERVER_URL,
        verify=True,
        proxy=True,
        api_token=None,
        username="user",
        password="pwd",
    )

    mocker.patch.object(CapeSandbox.time, "time", return_value=int(time.time()) + 10)

    assert client_auth.ensure_token() == "CACHED_TOKEN"


def test_ensure_token_invalid_cache_renewal(mocker, mock_context, requests_mock):
    """Tests token renewal logic when cache is invalid."""
    set_integration_context({"auth_info": {"token": "BAD_TOKEN", "valid_until": "NOT_A_NUMBER"}})

    requests_mock.post(
        f"{SERVER_URL}/{CapeSandbox.API_AUTH}",
        json={"token": "NEW_TOKEN"},
        status_code=200,
    )

    client_auth = CapeSandboxClient(
        base_url=SERVER_URL,
        verify=True,
        proxy=True,
        api_token=None,
        username="user",
        password="pwd",
    )
    token = client_auth.ensure_token()

    assert token == "NEW_TOKEN"
    assert get_integration_context().get("auth_info", {}).get("token") == "NEW_TOKEN"


def test_ensure_token_expired_renewal(mocker, mock_context, requests_mock):
    """Tests token renewal logic when cache is expired."""
    mock_time = int(time.time()) - 3600
    set_integration_context({"auth_info": {"token": "EXPIRED_TOKEN", "valid_until": str(mock_time)}})

    requests_mock.post(
        f"{SERVER_URL}/{CapeSandbox.API_AUTH}",
        json={"token": "NEW_TOKEN_2"},
        status_code=200,
    )

    client_auth = CapeSandboxClient(
        base_url=SERVER_URL,
        verify=True,
        proxy=True,
        api_token=None,
        username="user",
        password="pwd",
    )
    token = client_auth.ensure_token()

    assert token == "NEW_TOKEN_2"


def test_ensure_token_renewal_api_fail(mocker, requests_mock):
    """Tests token renewal fails if API doesn't return a token key."""
    requests_mock.post(
        f"{SERVER_URL}/{CapeSandbox.API_AUTH}",
        json={"error": "failed"},
        status_code=200,
    )

    client_auth = CapeSandboxClient(
        base_url=SERVER_URL,
        verify=True,
        proxy=True,
        api_token=None,
        username="user",
        password="pwd",
    )

    with pytest.raises(DemistoException, match="Failed to obtain API token from CAPE response"):
        client_auth.ensure_token()


# ========================================
# Tests: Client HTTP & Error Handling
# ========================================


@pytest.mark.parametrize(
    "error_response,expected_match",
    [
        ({"error": True, "error_value": "Task not found."}, "Task not found"),
        ({"error": True, "message": "Internal Server Error."}, "Internal Server Error"),
        ({"error": True}, "Unknown API error occurred"),
    ],
)
def test_http_request_api_error_check(error_response, expected_match):
    """Tests _check_for_api_error successfully detects and raises DemistoException."""
    client = CapeSandboxClient(base_url=SERVER_URL, verify=True, proxy=True, api_token="TOKEN")
    with pytest.raises(DemistoException, match=expected_match):
        client._check_for_api_error(error_response, "test-suffix", "json")


@pytest.mark.parametrize(
    "binary_response,resp_type,expected_match",
    [
        # Binary content that contains JSON error
        (
            b'{"error": true, "error_value": "No screenshots created for task 23"}',
            "content",
            "No screenshots created",
        ),
        # Binary content that contains JSON error with message field
        (b'{"error": true, "message": "File not found"}', "content", "File not found"),
        # Binary content that contains JSON error without specific message
        (b'{"error": true}', "content", "Unknown API error occurred"),
    ],
)
def test_check_for_api_error_binary_json_error(binary_response, resp_type, expected_match):
    """Tests _check_for_api_error detects JSON errors in binary content responses."""
    client = CapeSandboxClient(base_url=SERVER_URL, verify=True, proxy=True, api_token="TOKEN")
    with pytest.raises(DemistoException, match=expected_match):
        client._check_for_api_error(binary_response, "test-suffix", resp_type)


@pytest.mark.parametrize(
    "binary_response,resp_type",
    [
        # Valid binary content (not JSON)
        (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR", "content"),
        # Binary content with invalid JSON
        (b"{invalid json}", "content"),
        # Binary content that's valid JSON but no error
        (b'{"status": "success", "data": "test"}', "content"),
        # Empty binary content
        (b"", "content"),
    ],
)
def test_check_for_api_error_binary_valid_content(binary_response, resp_type):
    """Tests _check_for_api_error doesn't raise for valid binary content."""
    client = CapeSandboxClient(base_url=SERVER_URL, verify=True, proxy=True, api_token="TOKEN")
    # Should not raise any exception
    client._check_for_api_error(binary_response, "test-suffix", resp_type)


def test_check_for_api_error_json_response_type():
    """Tests _check_for_api_error handles dict responses for json resp_type."""
    client = CapeSandboxClient(base_url=SERVER_URL, verify=True, proxy=True, api_token="TOKEN")
    # Should raise for error dict
    with pytest.raises(DemistoException, match="Task not found"):
        client._check_for_api_error({"error": True, "error_value": "Task not found"}, "test-suffix", "json")

    # Should not raise for non-error dict
    client._check_for_api_error({"status": "success", "data": []}, "test-suffix", "json")


@pytest.mark.parametrize(
    "limit,offset,expected_match",
    [
        (0, 0, "limit must be > 0"),
        (1, -1, "offset must be >= 0"),
    ],
)
def test_list_tasks_invalid_pagination_fail(client, limit, offset, expected_match):
    """Tests list_tasks fails on invalid limit/offset."""
    with pytest.raises(DemistoException, match=expected_match):
        client.list_tasks(limit=limit, offset=offset)


def test_list_tasks_happy_path(mocker, client):
    """Tests list_tasks calls API correctly."""
    mocker.patch.object(client, "_http_request", return_value={"data": []})
    client.list_tasks(limit=10, offset=5)
    client._http_request.assert_called_with(
        method="GET",
        headers=mocker.ANY,
        url_suffix=mocker.ANY,
        data=None,
        files=None,
        resp_type=mocker.ANY,
        ok_codes=mocker.ANY,
    )


@pytest.mark.parametrize(
    "method_name,hash_type",
    [
        ("files_view_by_md5", "MD5"),
        ("files_get_by_sha1", "SHA1"),
        ("files_view_by_sha256", "SHA256"),
    ],
)
def test_invalid_hash_format_fail(client, method_name, hash_type):
    """Tests that client methods fail on invalid hash format."""
    method = getattr(client, method_name)
    with pytest.raises(DemistoException, match=f"Invalid {hash_type} hash format"):
        method("bad_hash")


def test_view_machine_missing_name_fail(client):
    """Tests view_machine fails if machine_name is missing."""
    with pytest.raises(DemistoException, match="machine_name is required"):
        client.view_machine("")


# ========================================
# Tests: test-module Command
# ========================================
# Tests: 429 Rate Limit Handling
# ========================================


def test_extract_retry_wait_time_from_json_detail(client):
    """Tests _extract_retry_wait_time extracts seconds from JSON error message."""
    error_msg = 'Error in API call [429] - {"detail": "Request was throttled. Expected available in 35 seconds."}'
    wait_time = client._extract_retry_wait_time(error_msg)
    assert wait_time == 35


def test_extract_retry_wait_time_with_single_quotes(client):
    """Tests _extract_retry_wait_time handles single quotes in JSON."""
    error_msg = "Error [429] - {'detail': 'Expected available in 120 seconds.'}"
    wait_time = client._extract_retry_wait_time(error_msg)
    assert wait_time == 120


def test_extract_retry_wait_time_no_match_returns_default(client):
    """Tests _extract_retry_wait_time returns default delay when no match found."""
    error_msg = "Error [429] - Too Many Requests"
    wait_time = client._extract_retry_wait_time(error_msg)
    assert wait_time == CapeSandbox.RETRY_BASE_DELAY


def test_extract_retry_wait_time_invalid_json_returns_default(client):
    """Tests _extract_retry_wait_time returns default on invalid JSON."""
    error_msg = "Error [429] - {invalid json"
    wait_time = client._extract_retry_wait_time(error_msg)
    assert wait_time == CapeSandbox.RETRY_BASE_DELAY


def test_handle_429_error_should_retry(mocker, client):
    """Tests _handle_429_error returns True for 429 error within retry limit."""
    error = DemistoException("Error [429] - Too Many Requests")
    mocker.patch.object(client, "_extract_retry_wait_time", return_value=5)
    mocker.patch.object(CapeSandbox.time, "sleep")

    should_retry = client._handle_429_error(error, attempt=0, method="GET", endpoint="/test")

    assert should_retry is True
    CapeSandbox.time.sleep.assert_called_once_with(5)


def test_handle_429_error_max_retries_exceeded(mocker, client, capfd):
    """Tests _handle_429_error returns False when max retries exceeded."""
    error = DemistoException("Error [429] - Too Many Requests")
    mocker.patch.object(client, "_extract_retry_wait_time", return_value=5)

    # Attempt 2 is the last attempt (0, 1, 2 = 3 attempts total)
    with capfd.disabled():
        should_retry = client._handle_429_error(
            error,
            attempt=CapeSandbox.MAX_RETRY_ATTEMPTS - 1,
            method="GET",
            endpoint="/test",
        )

    assert should_retry is False


def test_handle_429_error_non_429_error_no_retry(client):
    """Tests _handle_429_error returns False for non-429 errors."""
    error = DemistoException("Error [500] - Internal Server Error")

    should_retry = client._handle_429_error(error, attempt=0, method="GET", endpoint="/test")

    assert should_retry is False


def test_http_request_retries_on_429(mocker, client):
    """Tests http_request retries on 429 error and succeeds on second attempt."""
    # First call raises 429, second call succeeds
    mocker.patch.object(
        client,
        "_http_request",
        side_effect=[
            DemistoException('Error [429] - {"detail": "Expected available in 5 seconds."}'),
            {"data": "success"},
        ],
    )
    mocker.patch.object(CapeSandbox.time, "sleep")

    result = client.http_request("GET", url_suffix="/test")

    assert result == {"data": "success"}
    assert client._http_request.call_count == 2
    CapeSandbox.time.sleep.assert_called_once()


def test_http_request_fails_after_max_retries(mocker, client, capfd):
    """Tests http_request raises error after max retry attempts."""
    error_429 = DemistoException("Error [429] - Too Many Requests")
    mocker.patch.object(client, "_http_request", side_effect=error_429)
    mocker.patch.object(CapeSandbox.time, "sleep")

    with capfd.disabled(), pytest.raises(DemistoException, match="Too Many Requests"):
        client.http_request("GET", url_suffix="/test")

    # Should attempt MAX_RETRY_ATTEMPTS times
    assert client._http_request.call_count == CapeSandbox.MAX_RETRY_ATTEMPTS


def test_http_request_non_429_error_no_retry(mocker, client):
    """Tests http_request doesn't retry on non-429 errors."""
    error_500 = DemistoException("Error [500] - Internal Server Error")
    mocker.patch.object(client, "_http_request", side_effect=error_500)

    with pytest.raises(DemistoException, match="Internal Server Error"):
        client.http_request("GET", url_suffix="/test")

    # Should only attempt once (no retries)
    assert client._http_request.call_count == 1


# ========================================


def test_test_module(mocker, client):
    """Tests test_module command returns 'ok'."""
    mocker.patch.object(client, "ensure_token", return_value="TOKEN_OK")
    result = CapeSandbox.test_module(client)
    assert result == "ok"


def test_test_module_failure(mocker, client):
    """Tests test_module command fails when connectivity fails."""
    client_auth = CapeSandboxClient(
        base_url=SERVER_URL,
        verify=True,
        proxy=True,
        api_token=None,
        username="user",
        password="pwd",
    )
    mocker.patch.object(client_auth, "ensure_token", side_effect=DemistoException("Auth server down."))

    with pytest.raises(DemistoException, match="Auth server down"):
        CapeSandbox.test_module(client_auth)


# ========================================
# Tests: File & URL Submission Commands
# ========================================


def test_cape_file_submit_command(mocker, client):
    """Tests cape_file_submit_command returns correct outputs."""
    mocker.patch.object(
        CapeSandbox,
        "extract_entry_file_data",
        return_value=("test_data/test_file.txt", "test_file.txt"),
    )
    mocker.patch.object(
        client,
        "submit_file",
        return_value={"data": {"task_ids": [123]}},
    )
    mocker.patch.object(
        CapeSandbox,
        "initiate_polling",
        return_value=CommandResults(
            readable_output="Polling initiated for task 123",
            outputs={"id": 123, "target": "test_file.txt", "status": "pending"},
            outputs_prefix="Cape.Task.File",
        ),
    )

    args = {"entry_id": "test_entry_id"}
    result = cape_file_submit_command(client, args)
    assert compare_string_ignore_case(result.readable_output, "Polling initiated for task 123")
    assert result.outputs_prefix == "Cape.Task.File"
    assert isinstance(result.outputs, dict)
    outputs = result.outputs
    assert isinstance(outputs, dict)
    assert outputs.get("id") == 123


def test_cape_file_submit_command_no_task_id_fail(mocker, client):
    """Tests submission fails if API returns no task ID."""
    mocker.patch.object(
        CapeSandbox,
        "extract_entry_file_data",
        return_value=("path/to/file", "file.exe"),
    )
    mocker.patch.object(client, "submit_file", return_value={"data": {"task_ids": []}})

    with pytest.raises(DemistoException, match="No task id returned from CAPE"):
        cape_file_submit_command(client, {"entry_id": "e_id"})


def test_cape_file_submit_command_pcap_logic(mocker, client):
    """Tests submission correctly identifies and sets the pcap flag."""
    mocker.patch.object(
        CapeSandbox,
        "extract_entry_file_data",
        return_value=("path/to/file.pcap", "file.pcap"),
    )
    mocker.patch.object(client, "submit_file", return_value={"data": {"task_ids": [100]}})
    mocker.patch.object(CapeSandbox, "initiate_polling", return_value=CommandResults())

    cape_file_submit_command(client, {"entry_id": "e_id"})

    client.submit_file.assert_called_with(form=mocker.ANY, file_path=mocker.ANY, is_pcap=True)


def test_cape_file_submit_file_not_found_fail(mocker, client):
    """Tests file submission fails when file is not found."""
    mocker.patch.object(
        CapeSandbox,
        "extract_entry_file_data",
        side_effect=DemistoException("Could not find file with entry ID 'invalid_id'."),
    )
    args = {"entry_id": "invalid_id"}

    with pytest.raises(DemistoException) as excinfo:
        cape_file_submit_command(client, args)

    assert "could not find file" in str(excinfo.value).lower()


def test_cape_url_submit_command(mocker, client):
    """Tests cape_url_submit_command returns correct outputs."""
    mocker.patch.object(
        client,
        "submit_url",
        return_value={"data": {"task_ids": [456]}},
    )
    mocker.patch.object(
        CapeSandbox,
        "initiate_polling",
        return_value=CommandResults(
            readable_output="Polling initiated for URL task 456",
            outputs={
                "id": 456,
                "target": "http://test.example.com",
                "status": "pending",
            },
            outputs_prefix="Cape.Task.Url",
        ),
    )

    args = {"url": "http://test.example.com"}
    result = cape_url_submit_command(client, args)
    assert compare_string_ignore_case(result.readable_output, "Polling initiated for URL task 456")
    assert result.outputs_prefix == "Cape.Task.Url"
    assert isinstance(result.outputs, dict)
    outputs = result.outputs
    assert isinstance(outputs, dict)
    assert outputs.get("id") == 456


def test_cape_url_submit_command_no_url_fail(client):
    """Tests URL submission fails if URL argument is missing."""
    with pytest.raises(KeyError):
        cape_url_submit_command(client, {})


def test_cape_url_submit_command_no_task_id_fail(mocker, client):
    """Tests URL submission fails if API returns no task ID."""
    mocker.patch.object(client, "submit_url", return_value={"data": {"task_ids": None}})

    with pytest.raises(DemistoException, match="No task id returned from CAPE"):
        cape_url_submit_command(client, {"url": "http://test.example.com"})


# ========================================
# Tests: File View & Download Commands
# ========================================


@pytest.mark.parametrize(
    "id_type,id_value,client_method",
    [
        ("task_id", "123", "files_view_by_task"),
        ("md5", MOCK_MD5, "files_view_by_md5"),
        ("sha256", MOCK_SHA256, "files_view_by_sha256"),
    ],
)
def test_cape_file_view_command_by_id_type(mocker, client, id_type, id_value, client_method):
    """Tests cape_file_view_command with different ID types."""
    mocker.patch.object(
        client,
        client_method,
        return_value=util_load_json("cape_file_view_response.json"),
    )
    args = {id_type: id_value}
    result = cape_file_view_command(client, args)
    assert isinstance(result.outputs, dict)
    outputs = result.outputs
    assert isinstance(outputs, dict)
    assert outputs["id"] == "test_task_id"


@pytest.mark.parametrize(
    "id_type,id_value,client_method,expected_filename",
    [
        ("task_id", "123", "files_get_by_task", "cape_task_123_file.bin"),
        ("md5", MOCK_MD5, "files_get_by_md5", f"cape_file_{MOCK_MD5}.bin"),
        ("sha1", MOCK_SHA1, "files_get_by_sha1", f"cape_file_{MOCK_SHA1}.bin"),
        ("sha256", MOCK_SHA256, "files_get_by_sha256", f"cape_file_{MOCK_SHA256}.bin"),
    ],
)
def test_cape_sample_file_download_command_by_id_type(mocker, client, id_type, id_value, client_method, expected_filename):
    """Tests cape_sample_file_download_command with different ID types."""
    mock_content = util_load_file("cape_sample_file_download_response.bin")
    mocker.patch.object(client, client_method, return_value=mock_content)
    mocker.patch.object(CapeSandbox, "build_file_name", return_value=expected_filename)
    mocker.patch(
        "CapeSandbox.fileResult",
        return_value={"File": expected_filename, "Contents": mock_content},
    )
    args = {id_type: id_value}
    result = cape_sample_file_download_command(client, args)
    assert result["File"] == expected_filename


@pytest.mark.parametrize(
    "command_func,args",
    [
        (cape_file_view_command, {"task_id": 1, "md5": MOCK_MD5}),
        (cape_sample_file_download_command, {"task_id": 1, "sha1": MOCK_SHA1}),
    ],
)
def test_commands_multiple_ids_fail(client, command_func, args):
    """Tests commands fail if multiple ID types are provided."""
    with pytest.raises(DemistoException, match="Provide only one of"):
        command_func(client, args)


@pytest.mark.parametrize(
    "command_func",
    [
        cape_file_view_command,
        cape_sample_file_download_command,
    ],
)
def test_commands_no_id_fail(client, command_func):
    """Tests commands fail when no identifying argument is provided."""
    args = {}
    with pytest.raises(DemistoException) as excinfo:
        command_func(client, args)
    assert "provide one of:" in str(excinfo.value).lower()


# ========================================
# Tests: Task Management Commands
# ========================================


def test_cape_pcap_file_download_command(mocker, client):
    """Tests cape_pcap_file_download_command returns fileResult."""
    mock_pcap_content = util_load_file("cape_pcap_file_download_response.pcap")
    expected_filename = "cape_task_123_network_dump.pcap"
    mocker.patch.object(client, "get_task_pcap", return_value=mock_pcap_content)
    mocker.patch.object(CapeSandbox, "build_file_name", return_value=expected_filename)
    mocker.patch(
        "CapeSandbox.fileResult",
        return_value={"File": expected_filename, "Contents": mock_pcap_content},
    )
    args = {"task_id": "123"}
    result = cape_pcap_file_download_command(client, args)
    assert result["File"] == expected_filename


@pytest.mark.parametrize(
    "command_func",
    [
        cape_pcap_file_download_command,
        cape_task_delete_command,
        cape_task_report_get_command,
        cape_task_screenshot_download_command,
    ],
)
def test_commands_missing_task_id_fail(client, command_func):
    """Tests commands fail when required task_id is missing."""
    args = {}
    with pytest.raises(KeyError):
        command_func(client, args)


def test_cape_task_delete_command(mocker, client):
    """Tests cape_task_delete_command returns success message."""
    mocker.patch.object(
        client,
        "delete_task",
        return_value={"status": "success", "message": "Task deleted successfully"},
    )
    args = {"task_id": "123"}
    result = cape_task_delete_command(client, args)
    expected_output = "Task ID: 123 was deleted successfully"
    assert compare_string_ignore_case(result.readable_output, expected_output)


def test_cape_task_delete_command_no_task_id_fail(client):
    """Tests cape_task_delete_command fails when task_id is missing."""
    args = {}
    with pytest.raises(KeyError):
        cape_task_delete_command(client, args)


def test_cape_tasks_list_command_multiple_tasks(mocker, client):
    """Tests cape_tasks_list_command returns multiple task outputs."""
    mocker.patch.object(
        client,
        "list_tasks",
        return_value=util_load_json("cape_tasks_list_response.json"),
    )
    args = {"limit": 2, "page": 1}
    result = cape_tasks_list_command(client, args)
    assert result.outputs_prefix == "Cape.Task"
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 2
    assert result.outputs[0].get("id") == 1


@pytest.mark.parametrize(
    "args,expected_limit,expected_offset",
    [
        ({"page": -1}, 50, 0),
        ({"page_size": 200}, 50, 0),
    ],
)
def test_cape_tasks_list_command_invalid_pagination(mocker, client, args, expected_limit, expected_offset):
    """Tests task list command handles invalid page size/page number."""
    mocker.patch.object(client, "list_tasks", return_value={"data": []})
    cape_tasks_list_command(client, args)
    client.list_tasks.assert_called_with(limit=expected_limit, offset=expected_offset)


# ========================================
# Tests: Task Report Commands
# ========================================


def test_cape_task_report_get_command_json(mocker, client):
    """Tests cape_task_report_get_command returns JSON report."""
    mocker.patch.object(
        client,
        "get_task_report",
        return_value=util_load_json("cape_task_report_get_response.json"),
    )
    args = {"task_id": "123", "format": "json", "zip": False}
    result = cape_task_report_get_command(client, args)
    assert result.outputs_prefix == "Cape.Task.Report"
    assert isinstance(result.outputs, dict)
    assert result.outputs["id"] == 123


def test_cape_task_report_get_command_zip_download(mocker, client):
    """Tests cape_task_report_get_command returns zip fileResult."""
    mock_content = b"mock zip content"
    mocker.patch.object(client, "get_task_report", return_value=mock_content)
    mocker.patch.object(CapeSandbox, "build_file_name", return_value="report.zip")
    mocker.patch(
        "CapeSandbox.fileResult",
        return_value={"File": "report.zip", "Contents": mock_content},
    )

    args = {"task_id": "123", "zip": True}
    result = cape_task_report_get_command(client, args)
    assert result["File"] == "report.zip"


def test_cape_task_report_get_command_no_info_fail(mocker, client):
    """Tests report retrieval raises error if report is empty/missing 'info' key."""
    mocker.patch.object(
        client,
        "get_task_report",
        return_value={"status": True, "message": "Report is empty"},
    )

    with pytest.raises(DemistoException, match="Report is empty"):
        cape_task_report_get_command(client, {"task_id": 123})


def test_cape_task_report_get_command_json_no_info_path(mocker, client):
    """Tests report retrieval fails when 'info' is missing."""
    mock_resp = {
        "status": True,
        "target": {"file": {"name": "test.txt"}},
        "id": 123,
    }
    mocker.patch.object(client, "get_task_report", return_value=mock_resp)
    mocker.patch("CapeSandbox.string_to_table_header", side_effect=lambda x: x.upper())

    with pytest.raises(DemistoException, match="No info object found in report for task"):
        cape_task_report_get_command(client, {"task_id": 123})


def test_cape_task_report_get_command_json_target_file_data(mocker, client):
    """Tests report retrieval includes target file data in readable output."""
    mock_resp = {
        "info": {"id": 123, "started": "1970-01-01"},
        "target": {"file": {"name": "test.exe", "sha256": MOCK_SHA256}},
    }
    mocker.patch.object(client, "get_task_report", return_value=mock_resp)
    mocker.patch("CapeSandbox.string_to_table_header", side_effect=lambda x: x.upper())

    result = cape_task_report_get_command(client, {"task_id": "123"})
    assert MOCK_SHA256 in result.readable_output
    assert "test.exe" in result.readable_output


def test_cape_task_report_get_command_not_found_fail(mocker, client):
    """Tests report retrieval fails when API returns 404."""
    mocker.patch.object(
        client,
        "get_task_report",
        side_effect=DemistoException("Task with ID 999 not found on CAPE sandbox.", res={"status": 404}),
    )

    args = {"task_id": 999, "format": "json", "zip": False}

    with pytest.raises(DemistoException) as excinfo:
        cape_task_report_get_command(client, args)

    assert "999 not found" in str(excinfo.value)


# ========================================
# Tests: Screenshot Commands
# ========================================


def test_cape_task_screenshot_download_single(mocker, client):
    """Tests downloading a single, specified screenshot number."""
    mock_content = util_load_file("mock_screenshot_content_1.png")
    mocker.patch.object(client, "get_task_screenshot", return_value=mock_content)
    mocker.patch.object(CapeSandbox, "build_file_name", return_value="screenshot_5.png")
    mocker.patch("CapeSandbox.fileResult", return_value={"File": "screenshot_5.png"})

    args = {"task_id": "123", "screenshot": 5}
    result = cape_task_screenshot_download_command(client, args)
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 1
    assert result.outputs[0]["File"] == "screenshot_5.png"


def test_cape_task_screenshot_download_all_zip_success(mocker, client):
    """
    Tests the 'download all' (no 'screenshot' arg) success path.
    - Mocks the client's 'download_all_screenshots_zip' to return bytes.
    - Verifies the correct file is created.
    """
    mocker.patch.object(client, "download_all_screenshots_zip", return_value=b"zip_content")
    mocker.patch("CapeSandbox.fileResult", return_value={"Name": "mocked.zip"})

    args = {"task_id": "123"}
    results = cape_task_screenshot_download_command(client, args)

    client.download_all_screenshots_zip.assert_called_with(task_id="123")

    assert results.outputs is not None
    assert isinstance(results.outputs, list)
    assert len(results.outputs) == 1
    assert results.outputs_prefix == "Cape.Task.Screenshot"
    assert results.outputs[0]["Name"] == "mocked.zip"
    assert "All Screenshots (ZIP)" in results.readable_output


def test_cape_task_screenshot_download_single_success(mocker, client):
    """
    Tests the 'download single' (with 'screenshot' arg) success path.
    - Mocks the client's 'get_task_screenshot' to return bytes.
    - Verifies the correct file is created.
    """
    mocker.patch.object(client, "get_task_screenshot", return_value=b"png_content")
    mocker.patch(
        "CapeSandbox.fileResult",
        return_value={"Name": "mocked.png", "ScreenshotNumber": "5"},
    )

    args = {"task_id": "123", "screenshot": "5"}
    results = cape_task_screenshot_download_command(client, args)

    client.get_task_screenshot.assert_called_with(task_id="123", number="5")

    assert results.outputs is not None
    assert isinstance(results.outputs, list)
    assert len(results.outputs) == 1
    assert results.outputs_prefix == "Cape.Task.Screenshot"
    assert results.outputs[0]["Name"] == "mocked.png"
    assert results.outputs[0]["ScreenshotNumber"] == "5"
    assert "Screenshot 5" in results.readable_output


def test_cape_task_screenshot_download_failure(mocker, client):
    """
    Tests that a client-side exception is correctly raised.
    This test works for BOTH 'all' and 'single' paths.
    """
    # Mock the 'single' path to fail
    mocker.patch.object(client, "get_task_screenshot", side_effect=DemistoException("Image Not Found"))

    args = {"task_id": "123", "screenshot": "99"}

    # The command should catch the DemistoException and re-raise it
    with pytest.raises(
        DemistoException,
        match="Failed to fetch screenshots for task 123: Image Not Found",
    ):
        cape_task_screenshot_download_command(client, args)

    # Mock the 'all' path to fail
    mocker.patch.object(
        client,
        "download_all_screenshots_zip",
        side_effect=DemistoException("ZIP Not Found"),
    )

    args_all = {"task_id": "123"}
    with pytest.raises(
        DemistoException,
        match="Failed to fetch screenshots for task 123: ZIP Not Found",
    ):
        cape_task_screenshot_download_command(client, args_all)


# ========================================
# Tests: Machine Commands
# ========================================


def test_cape_machines_list_command_single_machine_view_by_name(mocker, client):
    """Tests cape_machines_list_command returns single machine."""
    mock_resp = {"machine": {"id": 5, "name": "win7"}}
    mocker.patch.object(client, "view_machine", return_value=mock_resp)

    result = cape_machines_list_command(client, {"machine_name": "win7"})

    assert result.outputs_prefix == "Cape.Machine"
    assert result.outputs is not None
    assert isinstance(result.outputs, dict)
    assert result.outputs["id"] == 5


def test_cape_machines_list_command_multiple_machines(mocker, client):
    """Tests cape_machines_list_command returns multiple machines."""
    mocker.patch.object(
        client,
        "list_machines",
        return_value=util_load_json("cape_machines_list_response.json"),
    )
    args = {"limit": 2, "all_results": False}
    result = cape_machines_list_command(client, args)
    assert result.outputs_prefix == "Cape.Machine"
    assert result.outputs is not None
    assert isinstance(result.outputs, list)
    assert len(result.outputs) == 2
    assert result.outputs[0].get("id") == 1


# ========================================
# Tests: Status Commands
# ========================================


def test_cape_cuckoo_status_get_command_unexpected_response(mocker, client):
    """Tests cuckoo status command handles empty/non-dict response gracefully."""
    mocker.patch.object(client, "get_cuckoo_status", return_value={"message": "status ok"})

    result = cape_cuckoo_status_get_command(client, {})

    assert result.readable_output is not None
    assert "N/A" in result.readable_output


# ========================================
# Tests: Polling Functions
# ========================================


def test_initiate_polling_basic(mocker):
    """Tests initiate_polling creates correct CommandResults with ScheduledCommand."""
    result = CapeSandbox.initiate_polling(
        command="test-command",
        args={"arg1": "value1"},
        task_id=123,
        api_target="test.exe",
        outputs_prefix="Cape.Task.File",
    )

    assert isinstance(result, CommandResults)
    assert result.outputs_prefix == "Cape.Task.File"
    outputs = result.outputs
    assert isinstance(outputs, dict)
    assert outputs["id"] == 123
    assert outputs["target"] == "test.exe"
    assert outputs["status"] == "pending"
    assert "Polling initiated" in result.readable_output
    assert result.scheduled_command is not None
    scheduled_cmd = result.scheduled_command
    assert scheduled_cmd._command == "cape-task-poll"  # type: ignore[attr-defined]


def test_initiate_polling_custom_intervals():
    """Tests initiate_polling creates CommandResults with ScheduledCommand."""
    result = CapeSandbox.initiate_polling(
        command="test-command",
        args={"pollingInterval": 30, "pollingTimeout": 600},
        task_id=456,
        api_target="malware.dll",
        outputs_prefix="Cape.Task.File",
    )

    # Just verify the scheduled command exists and has the right command name
    assert result.scheduled_command is not None
    scheduled_cmd = result.scheduled_command
    assert scheduled_cmd._command == "cape-task-poll"  # type: ignore[attr-defined]


def test_cape_task_poll_report_non_429_error_raises(mocker, client):
    """Tests cape_task_poll_report raises non-429 errors."""
    mocker.patch.object(
        client,
        "get_task_status",
        side_effect=DemistoException("Error [500] - Internal Server Error"),
    )

    args = {"task_id": "888", "outputs_prefix": "Cape.Task.File"}

    with pytest.raises(DemistoException, match="Internal Server Error"):
        CapeSandbox.cape_task_poll_report(args, client)


# ========================================
# Tests: Main Function
# ========================================


def test_main_invalid_command_fail(mocker):
    """Tests main() raises error for invalid/unimplemented command."""
    mocker.patch.object(demisto, "command", return_value="invalid-command")
    mocker.patch.object(demisto, "params", return_value={"url": SERVER_URL, "api_token": "TOKEN"})
    mocker.patch.object(demisto, "args", return_value={})

    mock_return_error = mocker.patch("CapeSandbox.return_error")

    CapeSandbox.main()

    # Verify return_error was called with appropriate message
    mock_return_error.assert_called_once()
    error_call_args = mock_return_error.call_args[0][0]
    assert "invalid-command" in error_call_args
    assert "not implemented" in error_call_args.lower()


def test_main_test_module_success(mocker):
    """Tests main() executes test-module command successfully."""
    mocker.patch.object(demisto, "command", return_value="test-module")
    mocker.patch.object(demisto, "params", return_value={"url": SERVER_URL, "api_token": "TOKEN"})
    mocker.patch.object(demisto, "args", return_value={})

    mock_return_results = mocker.patch("CapeSandbox.return_results")

    CapeSandbox.main()

    mock_return_results.assert_called_once_with("ok")


def test_main_cape_file_submit_success(mocker):
    """Tests main() executes cape-file-submit command successfully."""
    mocker.patch.object(demisto, "command", return_value="cape-file-submit")
    mocker.patch.object(demisto, "params", return_value={"url": SERVER_URL, "api_token": "TOKEN"})
    mocker.patch.object(demisto, "args", return_value={"entry_id": "test_id"})
    mocker.patch.object(
        CapeSandbox,
        "extract_entry_file_data",
        return_value=("/tmp/file.exe", "file.exe"),
    )

    mock_client = mocker.MagicMock()
    mock_client.submit_file.return_value = {"data": {"task_ids": [123]}}
    mocker.patch.object(CapeSandbox, "CapeSandboxClient", return_value=mock_client)

    mock_return_results = mocker.patch("CapeSandbox.return_results")
    mocker.patch.object(
        CapeSandbox,
        "initiate_polling",
        return_value=CommandResults(readable_output="Polling initiated"),
    )

    CapeSandbox.main()

    mock_return_results.assert_called_once()


def test_main_cape_task_poll_success(mocker):
    """Tests main() executes cape-task-poll command with correct argument passing."""
    mocker.patch.object(demisto, "command", return_value="cape-task-poll")
    mocker.patch.object(demisto, "params", return_value={"url": SERVER_URL, "api_token": "TOKEN"})
    mocker.patch.object(
        demisto,
        "args",
        return_value={"task_id": "123", "outputs_prefix": "Cape.Task.File"},
    )

    mock_client = mocker.MagicMock()
    mock_client.get_task_status.return_value = {"data": "running"}
    mocker.patch.object(CapeSandbox, "CapeSandboxClient", return_value=mock_client)

    mock_return_results = mocker.patch("CapeSandbox.return_results")

    CapeSandbox.main()

    # Verify return_results was called (polling result)
    mock_return_results.assert_called_once()


def test_main_command_execution_error(mocker):
    """Tests main() handles command execution errors gracefully."""
    mocker.patch.object(demisto, "command", return_value="cape-file-view")
    mocker.patch.object(demisto, "params", return_value={"url": SERVER_URL, "api_token": "TOKEN"})
    mocker.patch.object(demisto, "args", return_value={})

    mock_return_error = mocker.patch("CapeSandbox.return_error")

    CapeSandbox.main()

    # Verify error was handled
    mock_return_error.assert_called_once()
    error_message = mock_return_error.call_args[0][0]
    assert "cape-file-view" in error_message.lower()


@pytest.mark.parametrize(
    "command_name,expected_in_map",
    [
        ("test-module", True),
        ("cape-file-submit", True),
        ("cape-url-submit", True),
        ("cape-file-view", True),
        ("cape-sample-download", True),
        ("cape-tasks-list", True),
        ("cape-task-delete", True),
        ("cape-task-report-get", True),
        ("cape-pcap-file-download", True),
        ("cape-machines-list", True),
        ("cape-cuckoo-status-get", True),
        ("cape-task-poll", True),
        ("cape-task-screenshot-download", True),
        ("non-existent-command", False),
        ("", False),
    ],
)
def test_command_map_completeness(command_name, expected_in_map):
    """Tests that COMMAND_MAP contains all expected commands."""
    assert (command_name in CapeSandbox.COMMAND_MAP) == expected_in_map