SOCFWPackManager

The SOC Framework bootloader for Cortex XSIAM. Lists the SOC Framework pack catalog with the installed version and update status of each pack, installs and configures packs from xsoar_config.json, re-runs configuration only, and diagnoses the platform endpoints the install path depends on. Also includes sync-tags, a backward-compatible action for older SOC Framework deployments still using the value_tags lookup; modern versions use the SOCActionTimeMap_V3 list and do not require it.

python · SOC Framework Pack Manager

Details

IDSOCFWPackManager
Languagepython
From Version5.0.0
Docker Imagedemisto/python3:3.12.13.9059085
Tagsconfiguration Content Management SOC SOC_Framework SOC_Framework_Unified SOCFWBootloader

README

The SOC Framework bootloader for Cortex XSIAM. Lists the SOC Framework pack
catalog with the installed version and update status of each pack, installs
and configures packs from xsoar_config.json, re-runs configuration only, and
diagnoses the platform endpoints the install path depends on. Also includes
sync-tags, a backward-compatible action for older SOC Framework deployments
still using the value_tags lookup; modern versions use the
SOCActionTimeMap_V3 list and do not require it.

Script Data


Name Description
Script Type python3
Tags configuration, Content Management, SOC, SOC_Framework, SOC_Framework_Unified, SOCFWBootloader
Cortex XSOAR Version 5.0.0

Dependencies


This script uses the following commands and scripts.

  • Sleep

Inputs


Argument Name Description
action The action to run. The diagnose action probes every platform endpoint the marketplace install path depends on and reports which one fails; run it first when an install misbehaves. The list action shows the catalog. The apply action installs and configures a pack. The configure action re-runs configuration only, without installing a pack. The sync-tags action is a backward-compatible action that updates the legacy value_tags lookup; modern SOC Framework deployments use SOCActionTimeMap_V3 and do not need it.
probe_pack The pack ID used to probe the marketplace metadata and dependency endpoints, for the diagnose action only. Whois is a stock Marketplace pack present on effectively every tenant, which makes it a dependable read-only probe target; nothing is installed or modified. Falls back to an installed pack if this one is absent.
pack_id The pack ID from pack_catalog.json (for example, soc-optimization-unified). Required for action=apply.
catalog_url Override the catalog URL without modifying the integration instance parameters.
using Integration instance name to route core-api commands through. Defaults to the active instance.
include_hidden Allow installing packs where visible=false in the catalog.
dry_run Show what would happen without installing or configuring.
install_marketplace Whether to install marketplace_packs from xsoar_config.json.
skip_verify Pass-through to core-api-install-packs for ZIP installs.
skip_validation Pass-through to core-api-install-packs for ZIP installs.
apply_configure Whether to apply the config sections from xsoar_config.json (instances, jobs, lookups).
overwrite_lookup Overwrite the SOC Framework lookup table. Save your customizations first.
configure_jobs When action=apply, run job configuration from xsoar_config.json. Ignored if apply_configure=false.
configure_integrations When action=apply, create or update integration instances from xsoar_config.json. Ignored if apply_configure=false.
configure_lookups Whether to create or update lookup datasets from xsoar_config.json, for the apply and configure actions. A pack that ships a Lookup directory already brings its dataset with it, so configuring it again is redundant. Ignored when apply_configure is false.
retry_count Number of retry attempts for install or configure operations that fail transiently.
retry_sleep_seconds Seconds to wait between retry attempts.
execution_timeout Timeout in seconds for individual core-api commands invoked during configure.
install_timeout Timeout in seconds for the full custom-pack install command before falling back to polling.
post_install_poll_seconds After an install timeout, total seconds to poll the tenant for the pack to appear installed.
post_install_poll_interval_seconds Interval in seconds between install completion polls.
continue_on_install_timeout Continue with configuration steps if a custom-pack install times out and polling does not confirm installation.
upgrade_marketplace Whether a marketplace_packs entry of “latest” brings each requested pack to the newest published version, for the apply action only. When false, an already-installed pack keeps its current version. Mandatory dependencies are never force-upgraded either way; they move only when a minVersion requires it. Upgrading resolves versions from the marketplace, which returns large responses, so it is slower.
fail_on_marketplace_errors Raise on marketplace install errors instead of recording them and continuing.
debug Verbose War Room logging and additional install detail.
filter action=list only. Case-insensitive free-text filter applied to id, display_name, and path.
limit action=list only. Maximum number of rows to display per page.
offset action=list only. Row offset for paging. offset=0 shows the first page.
sort_by The column to sort by, for the list action only. Ignored for ordering between categories when group_by_category is true.
sort_dir action=list only. Sort direction.
visible_only action=list only. Hide packs marked visible=false in the catalog. Implied false when include_hidden=true.
fields The comma-separated list of columns to show, for the list action only. Available columns are id, display_name, category, version, installed, status, docs, visible and path. Unknown fields are ignored.
group_by_category Whether to group packs under their catalog category, one section per category, for the list action only. When false, a single flat list is rendered.
docs_base_url The absolute base URL of the documentation site, for the list action only. The catalog supplies each pack’s relative documentation path and this value supplies the host, so each pack links to its overview page. Must be absolute, otherwise the link resolves against the tenant host. Set to an empty value to disable linking.
output_format The output style for the list action only. The list style renders one line per pack, because the War Room transposes single-row tables and truncates long ones. The table style renders the columns named in fields.
show_total action=list only. If true, displays “showing X-Y of Z” paging information.
include_doc_content When printing pre_config_docs and post_config_docs, also fetch a truncated preview of the README content into the War Room output.
doc_content_max_chars Maximum characters per doc preview when include_doc_content=true.
doc_content_max_lines Maximum lines per doc preview when include_doc_content=true.
pre_config_done Set to true to acknowledge pre-config docs have been completed and continue with install or configure.
pre_config_gate When true, the script prints pre_config_docs and stops until pre_config_done=true.
force action=sync-tags only. Update value_tags even if the content hash matches the current version.
tags_url action=sync-tags only. Override the value_tags.json source URL. Defaults to the soc-optimization-unified pack on main.

Outputs


Path Description Type
SOCFramework.PackManager.pack_id Pack ID acted on for action=apply or action=configure. String
SOCFramework.PackManager.xsoar_config_url URL of the xsoar_config.json fetched for the pack. String
SOCFramework.PackManager.catalog_url URL of the pack catalog used to resolve the manifest. String
SOCFramework.PackManager.marketplace_errors Marketplace install errors recorded during action=apply. Unknown
SOCFramework.PackManager.configure_summary.integrations Integration instance configuration summary (attempted, ok, already_exists, failed). Unknown
SOCFramework.PackManager.configure_summary.jobs Job configuration summary (attempted, ok, failed, notes). Unknown
SOCFramework.PackManager.configure_summary.lookups Lookup dataset configuration summary (attempted, ok, failed). Unknown
SOCFramework.PackManager.SyncTags.status action=sync-tags result. up_to_date or updated. String
SOCFramework.PackManager.SyncTags.dataset Dataset name updated (value_tags). String
SOCFramework.PackManager.SyncTags.version Short hash of the value_tags content currently installed. String
SOCFramework.PackManager.SyncTags.hash Full content hash of the value_tags content currently installed. String
SOCFramework.PackManager.SyncTags.rows Number of value_tags rows uploaded to the dataset. Number
SOCFramework.PackManager.SyncTags.updated Whether the dataset was updated on this run. Boolean
SOCFramework.PackManager.SyncTags.previous_hash Previous content hash before this run, when applicable. String
SOCFramework.PackManager.SyncTags.updated_at ISO 8601 timestamp the value_tags dataset was last updated. String
"""
Unit tests for the SOCFWPackManager script.

Follows the pattern from SOCCommandWrapper_test.py.
Run: pytest Packs/SocFrameworkManager/Scripts/SOCFWPackManager_test.py
"""

import importlib
import json
import sys
import types

import pytest

SCRIPT_MODULE_NAME = "SOCFWPackManager"


def load_script():
    """Import SOCFWPackManager with mocked XSOAR runtime."""
    demisto_mock = types.SimpleNamespace()
    demisto_mock._args = {}
    demisto_mock._results = []
    demisto_mock._commands = []
    demisto_mock._command_responses = {}
    demisto_mock._context = {}

    def _execute_command(command, args):
        demisto_mock._commands.append((command, args))
        response = demisto_mock._command_responses.get(command)
        if callable(response):
            return response(args)
        return response if response is not None else []

    demisto_mock.args = lambda: demisto_mock._args
    demisto_mock.results = lambda x: demisto_mock._results.append(x)
    demisto_mock.executeCommand = _execute_command
    demisto_mock.debug = lambda x: None
    demisto_mock.info = lambda x: None
    demisto_mock.context = lambda: demisto_mock._context
    demisto_mock._modules = {}
    demisto_mock.getModules = lambda: demisto_mock._modules

    sys.modules["demistomock"] = demisto_mock

    common = types.ModuleType("CommonServerPython")
    common.return_results = lambda x: demisto_mock._results.append(x)
    common.return_error = lambda x: (_ for _ in ()).throw(RuntimeError(x))
    common.argToList = lambda v: (
        v if isinstance(v, list) else [x.strip() for x in str(v).split(",") if x.strip()] if v not in (None, "") else []
    )

    class _CommandResults:
        def __init__(self, outputs_prefix=None, outputs_key_field=None, outputs=None, readable_output=None):
            self.outputs_prefix = outputs_prefix
            self.outputs_key_field = outputs_key_field
            self.outputs = outputs
            self.readable_output = readable_output

    common.CommandResults = _CommandResults
    sys.modules["CommonServerPython"] = common

    if SCRIPT_MODULE_NAME in sys.modules:
        del sys.modules[SCRIPT_MODULE_NAME]

    module = importlib.import_module(SCRIPT_MODULE_NAME)

    # Inject demisto into the module namespace.
    # Scripts use demisto as a module-level global; importlib loses
    # the binding when sys.modules["demistomock"] is swapped between tests.
    module.demisto = demisto_mock
    module.return_results = lambda x: demisto_mock._results.append(x)
    module.return_error = lambda msg: (_ for _ in ()).throw(RuntimeError(msg))

    return module, demisto_mock


# ── Utility helpers ───────────────────────────────────────────────────────────


def test_arg_to_bool_true_variants():
    script, _ = load_script()
    for v in ("true", "True", "1", "yes", "y", "on", True):
        assert script.arg_to_bool(v) is True


def test_arg_to_bool_false_variants():
    script, _ = load_script()
    for v in ("false", "False", "0", "no", "n", "off", False):
        assert script.arg_to_bool(v) is False


def test_arg_to_bool_none_returns_default():
    script, _ = load_script()
    assert script.arg_to_bool(None, True) is True
    assert script.arg_to_bool(None, False) is False


def test_to_int_valid():
    script, _ = load_script()
    assert script.to_int("42", 0) == 42


def test_to_int_invalid_returns_default():
    script, _ = load_script()
    assert script.to_int("bad", 99) == 99


def test_guess_pack_id_from_label_strips_version():
    script, _ = load_script()
    assert script._guess_pack_id_from_label("soc-optimization-unified-v3.4.0.zip") == "soc-optimization-unified"
    assert script._guess_pack_id_from_label("SocFrameworkTrendMicroVisionOne-v1.0.30.zip") == "SocFrameworkTrendMicroVisionOne"
    assert script._guess_pack_id_from_label("soc-optimization-unified") == "soc-optimization-unified"


def test_is_timeout_error_detects_common_patterns():
    script, _ = load_script()
    assert script.is_timeout_error("read timed out") is True
    assert script.is_timeout_error("context deadline exceeded") is True
    assert script.is_timeout_error("awaiting headers") is True
    assert script.is_timeout_error("connection refused") is False


# ── Catalog helpers ───────────────────────────────────────────────────────────


def test_find_pack_in_catalog_found():
    script, _ = load_script()
    catalog = {
        "packs": [
            {"id": "soc-optimization-unified", "version": "3.6.3"},
            {"id": "SocFrameworkTrendMicroVisionOne", "version": "1.0.30"},
        ]
    }
    result = script.find_pack_in_catalog(catalog, "soc-optimization-unified")
    assert result["version"] == "3.6.3"


def test_find_pack_in_catalog_not_found():
    script, _ = load_script()
    catalog = {"packs": [{"id": "pack-a", "version": "1.0.0"}]}
    assert script.find_pack_in_catalog(catalog, "pack-z") is None


def test_find_pack_in_catalog_empty():
    script, _ = load_script()
    assert script.find_pack_in_catalog({}, "anything") is None


# ── xsoar_config helpers ──────────────────────────────────────────────────────


def test_extract_custom_packs_from_xsoar_cfg():
    script, _ = load_script()
    cfg = {
        "custom_packs": [
            {"id": "my-pack.zip", "url": "https://example.com/my-pack-v1.0.0.zip", "system": "true"},
        ]
    }
    result = script._extract_custom_packs_from_xsoar_cfg(cfg)
    assert len(result) == 1
    assert result[0]["url"] == "https://example.com/my-pack-v1.0.0.zip"
    assert result[0]["system"] == "true"


def test_extract_custom_packs_from_xsoar_cfg_empty():
    script, _ = load_script()
    assert script._extract_custom_packs_from_xsoar_cfg({}) == []
    assert script._extract_custom_packs_from_xsoar_cfg({"custom_packs": []}) == []


def test_has_config_docs_pre_true():
    script, _ = load_script()
    cfg = {"pre_config_docs": [{"name": "README", "url": "https://example.com/README.md"}]}
    assert script.has_config_docs(cfg, "pre") is True


def test_has_config_docs_post_false_when_empty():
    script, _ = load_script()
    assert script.has_config_docs({}, "post") is False
    assert script.has_config_docs({"post_config_docs": []}, "post") is False


# ── action=list ───────────────────────────────────────────────────────────────


def test_do_list_renders_table(mocker):
    script, demisto = load_script()
    catalog = {
        "packs": [
            {
                "id": "soc-optimization-unified",
                "display_name": "SOC Framework Unified",
                "version": "3.6.3",
                "visible": True,
                "path": "Packs/soc-optimization-unified",
            },
            {
                "id": "SocFrameworkTrendMicroVisionOne",
                "display_name": "SOC Trend Micro",
                "version": "1.0.30",
                "visible": True,
                "path": "Packs/SocFrameworkTrendMicroVisionOne",
            },
        ]
    }
    mocker.patch.object(script, "fetch_pack_catalog", return_value=catalog)

    script.do_list(
        {
            "limit": "50",
            "offset": "0",
            "sort_by": "id",
            "sort_dir": "asc",
            "fields": "id,display_name,version,visible,path",
            "show_total": "True",
        }
    )

    output = " ".join(str(r) for r in demisto._results)
    assert "soc-optimization-unified" in output
    assert "SocFrameworkTrendMicroVisionOne" in output
    assert "2 pack(s)" in output


def test_do_list_filter(mocker):
    script, demisto = load_script()
    catalog = {
        "packs": [
            {
                "id": "soc-optimization-unified",
                "display_name": "SOC Framework Unified",
                "version": "3.6.3",
                "visible": True,
                "path": "Packs/soc-optimization-unified",
            },
            {
                "id": "SocFrameworkTrendMicroVisionOne",
                "display_name": "Trend Micro",
                "version": "1.0.30",
                "visible": True,
                "path": "Packs/SocFrameworkTrendMicroVisionOne",
            },
        ]
    }
    mocker.patch.object(script, "fetch_pack_catalog", return_value=catalog)

    script.do_list({"filter": "trend", "limit": "50", "offset": "0", "sort_by": "id", "fields": "id,version"})

    output = " ".join(str(r) for r in demisto._results)
    assert "SocFrameworkTrendMicroVisionOne" in output
    assert "1 pack(s)" in output


# ── action=sync-tags ──────────────────────────────────────────────────────────


def test_compute_hash_is_stable():
    script, _ = load_script()
    rows = [{"ScriptID": "cs-falcon-contain-host", "Tag": "isolate", "Time": "5"}]
    h1 = script._compute_hash(rows)
    h2 = script._compute_hash(rows)
    assert h1 == h2
    assert len(h1) == 32  # MD5 hex


def test_compute_hash_differs_on_change():
    script, _ = load_script()
    rows_a = [{"ScriptID": "cs-falcon-contain-host", "Tag": "isolate", "Time": "5"}]
    rows_b = [{"ScriptID": "cs-falcon-contain-host", "Tag": "isolate", "Time": "10"}]
    assert script._compute_hash(rows_a) != script._compute_hash(rows_b)


def test_do_sync_tags_up_to_date(mocker):
    script, demisto = load_script()

    rows = [{"ScriptID": "test", "Tag": "t", "Time": "1"}]
    current_hash = script._compute_hash(rows)

    mocker.patch.object(script, "http_get_json", return_value=rows)
    mocker.patch.object(script, "_normalize_lookup_rows", return_value=rows)
    mocker.patch.object(script, "_remove_omitted_fields", return_value=rows)
    mocker.patch.object(
        script,
        "_get_current_meta",
        return_value={"hash": current_hash, "version": current_hash[:8], "updated_at": "2026-01-01T00:00:00Z"},
    )

    script.do_sync_tags({"force": "False"})

    results_flat = " ".join(json.dumps(r) if not isinstance(r, str) else r for r in demisto._results)
    assert "up_to_date" in results_flat


def test_do_sync_tags_updates_when_hash_differs(mocker):
    script, demisto = load_script()

    rows = [{"ScriptID": "new-script", "Tag": "t", "Time": "2"}]
    mocker.patch.object(script, "http_get_json", return_value=rows)
    mocker.patch.object(script, "_normalize_lookup_rows", return_value=rows)
    mocker.patch.object(script, "_remove_omitted_fields", return_value=rows)
    mocker.patch.object(
        script,
        "_get_current_meta",
        return_value={"hash": "old_hash_abc", "version": "old_hash", "updated_at": "2025-01-01T00:00:00Z"},
    )
    mock_upload = mocker.patch.object(script, "_xql_lookup_add_data_list")
    mock_set = mocker.patch.object(script, "_set_current_meta")

    script.do_sync_tags({"force": "False"})

    mock_upload.assert_called_once()
    mock_set.assert_called_once()
    upload_rows = mock_upload.call_args[1]["rows"] if mock_upload.call_args[1] else mock_upload.call_args[0][1]
    assert upload_rows == rows  # only data rows, no meta row in dataset


def test_do_sync_tags_force_updates_even_when_equal(mocker):
    script, demisto = load_script()

    rows = [{"ScriptID": "s", "Tag": "t", "Time": "1"}]
    current_hash = script._compute_hash(rows)

    mocker.patch.object(script, "http_get_json", return_value=rows)
    mocker.patch.object(script, "_normalize_lookup_rows", return_value=rows)
    mocker.patch.object(script, "_remove_omitted_fields", return_value=rows)
    mocker.patch.object(
        script,
        "_get_current_meta",
        return_value={"hash": current_hash, "version": current_hash[:8], "updated_at": "2026-01-01T00:00:00Z"},
    )
    mock_upload = mocker.patch.object(script, "_xql_lookup_add_data_list")
    mocker.patch.object(script, "_set_current_meta")

    script.do_sync_tags({"force": "True"})

    mock_upload.assert_called_once()


def test_do_sync_tags_no_rows_raises(mocker):
    script, _ = load_script()
    mocker.patch.object(script, "http_get_json", return_value=[])
    mocker.patch.object(script, "_normalize_lookup_rows", return_value=[])
    mocker.patch.object(script, "_remove_omitted_fields", return_value=[])

    with pytest.raises(Exception, match="0 usable rows"):
        script.do_sync_tags({})


# ── action=configure ──────────────────────────────────────────────────────────


def test_do_configure_missing_pack_id_raises():
    script, _ = load_script()
    with pytest.raises(Exception, match="pack_id is required"):
        script.do_configure({})


def test_do_configure_skips_lookups_by_default(mocker):
    script, _ = load_script()

    xsoar_cfg = {
        "integration_instances": [{"name": "inst1", "brand": "BrandA", "data": []}],
        "jobs": [],
        "lookup_datasets": [],
    }
    mock_catalog = {"packs": [{"id": "my-pack", "version": "1.0.0", "xsoar_config": "https://example.com/xsoar_config.json"}]}
    mocker.patch.object(script, "fetch_pack_catalog", return_value=mock_catalog)
    mocker.patch.object(script, "fetch_xsoar_config", return_value=xsoar_cfg)
    mocker.patch.object(script, "fetch_installed_marketplace_pack_ids", return_value=[])
    mock_integ = mocker.patch.object(script, "configure_integrations_from_xsoar_config")
    mock_jobs = mocker.patch.object(script, "configure_jobs_from_xsoar_config")
    mock_lookups = mocker.patch.object(script, "configure_lookups_from_xsoar_config")
    mocker.patch.object(script, "print_config_docs")

    script.do_configure({"pack_id": "my-pack"})

    mock_integ.assert_called_once()
    mock_jobs.assert_called_once()
    # configure_lookups defaults to false: a pack that ships a Lookup directory
    # already brings its dataset with it.
    mock_lookups.assert_not_called()

    script.do_configure({"pack_id": "my-pack", "configure_lookups": "true"})
    mock_lookups.assert_called_once()


def test_do_configure_respects_flags(mocker):
    script, _ = load_script()

    xsoar_cfg = {"integration_instances": [], "jobs": [], "lookup_datasets": []}
    mocker.patch.object(script, "fetch_pack_catalog", return_value={"packs": [{"id": "p", "version": "1.0.0"}]})
    mocker.patch.object(script, "fetch_xsoar_config", return_value=xsoar_cfg)
    mocker.patch.object(script, "fetch_installed_marketplace_pack_ids", return_value=[])
    mock_integ = mocker.patch.object(script, "configure_integrations_from_xsoar_config")
    mock_jobs = mocker.patch.object(script, "configure_jobs_from_xsoar_config")
    mock_lookups = mocker.patch.object(script, "configure_lookups_from_xsoar_config")
    mocker.patch.object(script, "print_config_docs")

    script.do_configure(
        {
            "pack_id": "p",
            "configure_integrations": "False",
            "configure_jobs": "False",
            "configure_lookups": "True",
        }
    )

    mock_integ.assert_not_called()
    mock_jobs.assert_not_called()
    mock_lookups.assert_called_once()


# ── action dispatch (main) ────────────────────────────────────────────────────


def test_main_unsupported_action_raises():
    script, demisto = load_script()
    demisto._args = {"action": "explode"}
    with pytest.raises(Exception, match="Unsupported action"):
        script.main()


def test_main_list_calls_do_list(mocker):
    script, demisto = load_script()
    demisto._args = {"action": "list"}
    mock_list = mocker.patch.object(script, "do_list")
    script.main()
    mock_list.assert_called_once()


def test_main_configure_calls_do_configure(mocker):
    script, demisto = load_script()
    demisto._args = {"action": "configure", "pack_id": "soc-optimization-unified"}
    mock_cfg = mocker.patch.object(script, "do_configure")
    script.main()
    mock_cfg.assert_called_once()


def test_main_sync_tags_calls_do_sync_tags(mocker):
    script, demisto = load_script()
    demisto._args = {"action": "sync-tags"}
    mock_sync = mocker.patch.object(script, "do_sync_tags")
    script.main()
    mock_sync.assert_called_once()


def test_main_apply_requires_pack_id():
    script, demisto = load_script()
    demisto._args = {"action": "apply"}
    with pytest.raises(Exception, match="pack_id is required"):
        script.main()


def test_main_apply_pre_config_gate_stops_when_docs_present(mocker):
    script, demisto = load_script()
    demisto._args = {
        "action": "apply",
        "pack_id": "SocFrameworkTrendMicroVisionOne",
        "pre_config_gate": "True",
        "pre_config_done": "False",
    }
    manifest = {
        "marketplace_packs": [],
        "custom_zip_urls": [],
        "xsoar_config_url": "https://example.com/xsoar_config.json",
        "pack_version": "1.0.30",
    }
    xsoar_cfg = {
        "pre_config_docs": [{"name": "README", "url": "https://example.com/README.md"}],
        "integration_instances": [],
        "jobs": [],
        "lookup_datasets": [],
    }
    mocker.patch.object(script, "resolve_manifest", return_value=manifest)
    mocker.patch.object(script, "fetch_xsoar_config", return_value=xsoar_cfg)
    mocker.patch.object(script, "print_config_docs")

    script.main()

    results_flat = json.dumps(demisto._results)
    assert "stopped_after_pre_docs" in results_flat or "pre_config" in results_flat


# ── Marketplace install ───────────────────────────────────────────────────────


def test_ver_key_orders_versions():
    script, _ = load_script()
    assert script._ver_key("1.2.3") == (1, 2, 3)
    assert script._ver_key("1.22.28") > script._ver_key("1.9.99")
    assert script._ver_key("") == (0, 0, 0)
    assert script._ver_key(None) == (0, 0, 0)
    assert script._ver_key("2.0") == (2, 0, 0)


def test_fetch_installed_packs_parses_version_and_update_flag():
    script, demisto_mock = load_script()
    demisto_mock._command_responses["core-api-get"] = [
        {
            "Type": 1,
            "Contents": {
                "response": [
                    {"id": "Base", "currentVersion": "1.42.2", "updateAvailable": True},
                    {"id": "Whois", "currentVersion": "1.5.42", "updateAvailable": False},
                    {"currentVersion": "9.9.9"},
                ]
            },
        }
    ]
    out = script.fetch_installed_packs("")
    assert out["Base"] == {"version": "1.42.2", "update_available": True}
    assert out["Whois"] == {"version": "1.5.42", "update_available": False}
    assert len(out) == 2


def test_fetch_installed_packs_returns_empty_on_error():
    script, demisto_mock = load_script()

    def boom(args):
        raise RuntimeError("500")

    demisto_mock._command_responses["core-api-get"] = boom
    assert script.fetch_installed_packs("") == {}


def test_fetch_mandatory_dependencies_filters_optional():
    script, _ = load_script()
    payload = {
        "response": {
            "packs": [
                {
                    "id": "CommonScripts",
                    "extras": {
                        "pack": {
                            "dependencies": {
                                "Base": {"mandatory": True, "minVersion": "1.42.1"},
                                "Core": {"mandatory": True, "minVersion": "3.5.65"},
                                "Optional": {"mandatory": False, "minVersion": "1.0.0"},
                            }
                        }
                    },
                }
            ]
        }
    }
    script.core_api_post = lambda path, body, using="": payload
    out = script.fetch_mandatory_dependencies([{"id": "CommonScripts", "version": "1.0.0"}], "")
    assert out == {"CommonScripts": {"Base": "1.42.1", "Core": "3.5.65"}}


def test_resolve_install_closure_expands_transitive_dependencies():
    script, _ = load_script()
    graph = {
        "CommonScripts": {"Base": "1.42.1", "DemistoRESTAPI": "1.4.5"},
        "Base": {"Core": "3.5.65"},
        "Core": {"CommonPlaybooks": "2.8.0"},
        "CommonPlaybooks": {"rasterize": "2.1.30"},
    }

    def fake_deps(packs, using):
        return {p["id"]: graph.get(p["id"], {}) for p in packs}

    script.fetch_mandatory_dependencies = fake_deps
    closure = script.resolve_install_closure([{"id": "CommonScripts", "version": "1.22.28"}], "", {})

    assert set(closure) == {
        "CommonScripts",
        "Base",
        "DemistoRESTAPI",
        "Core",
        "CommonPlaybooks",
        "rasterize",
    }
    assert closure["CommonScripts"] == "1.22.28"
    assert closure["Core"] == "3.5.65"


def test_resolve_install_closure_prefers_installed_version_for_latest():
    script, _ = load_script()
    script.fetch_mandatory_dependencies = lambda packs, using: {}

    def should_not_be_called(pack_id, using):
        raise AssertionError("marketplace lookup should be skipped when the pack is installed")

    script.resolve_latest_version = should_not_be_called
    installed = {"Whois": {"version": "1.5.42", "update_available": True}}
    closure = script.resolve_install_closure([{"id": "Whois", "version": "latest"}], "", installed)
    assert closure == {"Whois": "1.5.42"}


def test_resolve_install_closure_resolves_latest_when_not_installed():
    script, _ = load_script()
    script.fetch_mandatory_dependencies = lambda packs, using: {}
    script.resolve_latest_version = lambda pack_id, using: "9.9.9"
    closure = script.resolve_install_closure([{"id": "Whois", "version": "latest"}], "", {})
    assert closure == {"Whois": "9.9.9"}


def test_resolve_install_closure_upgrades_dependency_below_min_version():
    script, _ = load_script()
    script.fetch_mandatory_dependencies = lambda packs, using: (
        {"A": {"Base": "2.0.0"}} if packs and packs[0]["id"] == "A" else {}
    )
    installed = {"Base": {"version": "1.0.0", "update_available": True}}
    closure = script.resolve_install_closure([{"id": "A", "version": "1.0.0"}], "", installed)
    assert closure["Base"] == "2.0.0"


def test_install_marketplace_packs_sends_full_closure_in_object_body():
    script, demisto_mock = load_script()
    script.fetch_installed_packs = lambda using: {}
    script.resolve_install_closure = lambda seeds, using, installed, upgrade=False: {
        "CommonScripts": "1.22.28",
        "Base": "1.42.2",
    }
    demisto_mock._command_responses["core-api-post"] = [{"Type": 1, "Contents": {"response": []}}]

    result = script.install_marketplace_packs([{"id": "CommonScripts", "version": "latest"}], "", 0, 0, debug=False)

    posts = [c for c in demisto_mock._commands if c[0] == "core-api-post"]
    assert len(posts) == 1
    args = posts[0][1]
    assert args["uri"] == "/contentpacks/marketplace/install"
    body = json.loads(args["body"])
    # Object wrapper, and the dependency has to ride along with the requested pack.
    assert sorted(body["packs"], key=lambda p: p["id"]) == [
        {"id": "Base", "version": "1.42.2"},
        {"id": "CommonScripts", "version": "1.22.28"},
    ]
    assert result["installed"] == {"CommonScripts": "1.22.28", "Base": "1.42.2"}


def test_install_marketplace_packs_skips_when_already_satisfied():
    script, demisto_mock = load_script()
    script.fetch_installed_packs = lambda using: {"CommonScripts": {"version": "1.22.28", "update_available": False}}
    script.resolve_install_closure = lambda seeds, using, installed, upgrade=False: {"CommonScripts": "1.22.28"}

    result = script.install_marketplace_packs([{"id": "CommonScripts", "version": "latest"}], "", 0, 0, debug=False)

    assert [c for c in demisto_mock._commands if c[0] == "core-api-post"] == []
    assert result["installed"] == {}
    assert result["already_present"] == {"CommonScripts": "1.22.28"}


def test_install_marketplace_packs_emits_legacy_context_on_success():
    script, demisto_mock = load_script()
    script.fetch_installed_packs = lambda using: {}
    script.resolve_install_closure = lambda seeds, using, installed, upgrade=False: {
        "CommonScripts": "1.22.28",
        "Base": "1.42.2",
    }
    demisto_mock._command_responses["core-api-post"] = [{"Type": 1, "Contents": {"response": []}}]

    script.install_marketplace_packs([{"id": "CommonScripts", "version": "latest"}], "", 0, 0, debug=False)

    ctx = [r for r in demisto_mock._results if getattr(r, "outputs_prefix", None) == "ConfigurationSetup.MarketplacePacks"]
    assert len(ctx) == 1
    assert ctx[0].outputs_key_field == "packid"
    rows = {r["packid"]: r["installationstatus"] for r in ctx[0].outputs}
    assert rows["CommonScripts"] == "Success."
    assert rows["Base"] == "Installed as requirement."


def test_install_marketplace_packs_emits_legacy_context_on_failure():
    script, demisto_mock = load_script()
    script.fetch_installed_packs = lambda using: {}
    script.resolve_install_closure = lambda seeds, using, installed, upgrade=False: {"CommonScripts": "1.22.28"}

    def boom(args):
        raise RuntimeError("dependency missing")

    demisto_mock._command_responses["core-api-post"] = boom

    with pytest.raises(Exception):
        script.install_marketplace_packs([{"id": "CommonScripts", "version": "latest"}], "", 0, 0, debug=False)

    ctx = [r for r in demisto_mock._results if getattr(r, "outputs_prefix", None) == "ConfigurationSetup.MarketplacePacks"]
    assert ctx
    assert ctx[-1].outputs[0]["installationstatus"] == "Failed to install."


def test_marketplace_context_rows_match_legacy_shape():
    script, _ = load_script()
    rows = script._marketplace_context_rows({"A", "B", "C"}, {"A": "1.0.0", "D": "2.0.0"}, {"B": "3.0.0"}, {"C": "4.0.0"})
    by_id = {r["packid"]: r for r in rows}
    assert set(by_id["A"]) == {"packid", "packversion", "installationstatus"}
    assert by_id["A"]["installationstatus"] == "Success."
    assert by_id["D"]["installationstatus"] == "Installed as requirement."
    assert by_id["B"]["installationstatus"] == "Already Installed on the machine."
    assert by_id["C"]["installationstatus"] == "Failed to install."
    assert by_id["A"]["packversion"] == "1.0.0"


# ── list: drift + category grouping ───────────────────────────────────────────


def _list_catalog():
    return {
        "packs": [
            {"id": "soc-a", "display_name": "A", "category": "Endpoint", "version": "1.0.5", "visible": True},
            {"id": "soc-b", "display_name": "B", "category": "Endpoint", "version": "2.0.0", "visible": True},
            {"id": "soc-c", "display_name": "C", "category": "Identity", "version": "1.0.0", "visible": True},
        ]
    }


def test_do_list_reports_install_status_per_pack(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(
        script,
        "fetch_installed_packs",
        return_value={
            "soc-a": {"version": "1.0.4", "update_available": True},
            "soc-b": {"version": "2.0.0", "update_available": False},
        },
    )

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "update available" in out
    assert "up to date" in out
    assert "not installed" in out
    assert "1.0.4" in out


def test_do_list_groups_by_category_and_drops_visible_and_path(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={"soc-a": {"version": "1.0.5"}})

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "**ENDPOINT**" in out
    assert "**IDENTITY**" in out
    # visible/path are not rendered; "visible_only" in the summary line is fine.
    assert "| visible" not in out
    assert "Packs/soc-a" not in out


def test_do_list_defaults_to_list_not_table(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={"soc-a": {"version": "1.0.4"}})

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    # A markdown table gets transposed when a category has a single pack.
    assert "| --- |" not in out
    assert "**soc-a**" in out
    assert "🔵 **soc-a**  1.0.4 → 1.0.5" in out


def test_do_list_table_format_still_available(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={"soc-a": {"version": "1.0.5"}})

    script.do_list({"output_format": "table"})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "| --- |" in out
    assert "| installed" in out


def test_do_list_line_wording_per_status(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(
        script,
        "fetch_installed_packs",
        return_value={"soc-a": {"version": "1.0.5"}, "soc-b": {"version": "9.9.9"}},
    )

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "🟢 **soc-a**  1.0.5" in out
    assert "🟠 **soc-b**  9.9.9 installed · catalog 2.0.0" in out
    assert "⚪ **soc-c**  1.0.0 available" in out


def test_do_list_flat_when_grouping_disabled(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={})

    script.do_list({"group_by_category": "false"})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "**Endpoint**" not in out
    assert "soc-a" in out


def test_do_list_marks_status_unknown_when_installed_lookup_fails(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={})

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "unknown" in out
    assert "not installed" not in out


def test_do_list_flags_pack_ahead_of_catalog(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={"soc-a": {"version": "9.9.9"}})

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "ahead of catalog" in out


def test_pack_docs_url_uses_docs_path_basename():
    script, _ = load_script()
    url = script._pack_docs_url(
        {"id": "soc-wiz-cloud", "docs_path": "docs/soc-wiz-cloud"},
        "https://palo-cortex.github.io/secops-framework",
    )
    assert url == "https://palo-cortex.github.io/secops-framework/soc-wiz-cloud/overview/"


def test_pack_docs_url_is_absolute_and_tolerates_trailing_slash():
    script, _ = load_script()
    url = script._pack_docs_url(
        {"id": "soc-a", "docs_path": "docs/soc-a/"},
        "https://example.com/site/",
    )
    assert url.startswith("https://")
    assert url == "https://example.com/site/soc-a/overview/"


def test_pack_docs_url_falls_back_to_pack_id():
    script, _ = load_script()
    url = script._pack_docs_url({"id": "soc-b"}, "https://example.com")
    assert url == "https://example.com/soc-b/overview/"


def test_pack_docs_url_empty_when_base_disabled():
    script, _ = load_script()
    assert script._pack_docs_url({"id": "soc-b", "docs_path": "docs/soc-b"}, "") == ""


def test_do_list_links_pack_id_to_docs(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={"soc-a": {"version": "1.0.5"}})

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "**soc-a**" in out
    assert "[docs](https://palo-cortex.github.io/secops-framework/soc-a/overview/)" in out
    # Never emit a relative href -- XSIAM resolves it against the tenant host.
    assert "](/" not in out


def test_do_list_omits_link_when_docs_base_url_blank(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={})

    script.do_list({"docs_base_url": ""})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "soc-a" in out
    assert "](http" not in out


def test_pack_docs_url_prefers_explicit_absolute_docs_field():
    script, _ = load_script()
    url = script._pack_docs_url(
        {"id": "soc-a", "docs_path": "docs/soc-a", "docs": "https://example.com/custom/page/"},
        "https://palo-cortex.github.io/secops-framework",
    )
    assert url == "https://example.com/custom/page/"


def test_pack_docs_url_treats_relative_docs_field_as_a_path():
    script, _ = load_script()
    url = script._pack_docs_url(
        {"id": "soc-a", "docs": "docs/soc-a"},
        "https://example.com",
    )
    assert url == "https://example.com/soc-a/overview/"


def test_do_list_leaves_pack_id_unlinked_for_copy_paste(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={"soc-a": {"version": "1.0.4"}})

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    # The id has to be plain text so it can be pasted into pack_id=.
    assert "**soc-a**" in out
    assert "[soc-a](" not in out
    assert "[docs](https://palo-cortex.github.io/secops-framework/soc-a/overview/)" in out


def test_do_list_table_puts_docs_in_its_own_column(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={"soc-a": {"version": "1.0.5"}})

    script.do_list({"output_format": "table"})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "| docs |" in out
    assert "[soc-a](" not in out
    assert "[docs](" in out


def test_do_list_headings_are_not_absorbed_into_a_markdown_list(mocker):
    script, demisto_mock = load_script()
    mocker.patch.object(script, "fetch_pack_catalog", return_value=_list_catalog())
    mocker.patch.object(script, "fetch_installed_packs", return_value={"soc-a": {"version": "1.0.4"}})

    script.do_list({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    # A bold heading directly after a list item becomes a lazy continuation of
    # that list, which indents every heading after the first.
    assert "\n- " not in out
    for line in out.splitlines():
        if line.startswith("**") and line.endswith("**"):
            continue
        assert not line.lstrip().startswith("- ")


# ── diagnose + loud degradation ───────────────────────────────────────────────


def test_dependency_lookup_failure_raises_typed_error():
    script, demisto_mock = load_script()

    def boom(args):
        raise RuntimeError("404 route not found")

    demisto_mock._command_responses["core-api-post"] = boom
    with pytest.raises(script.DependencyLookupError):
        script.fetch_mandatory_dependencies([{"id": "CommonScripts", "version": "1.0.0"}], "")

    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))
    assert "Dependency lookup failed" in out
    assert "404 route not found" in out
    assert "not the install endpoint" in out


def test_install_does_not_attempt_when_dependencies_unresolvable():
    script, demisto_mock = load_script()
    script.fetch_installed_packs = lambda using: {}

    def raise_lookup(seeds, using, installed, upgrade=False):
        raise script.DependencyLookupError("dependency endpoint returned 500")

    script.resolve_install_closure = raise_lookup

    with pytest.raises(Exception) as exc:
        script.install_marketplace_packs([{"id": "CommonScripts", "version": "latest"}], "", 0, 0, debug=False)

    assert "was not attempted" in str(exc.value)
    assert "dependency endpoint returned 500" in str(exc.value)
    # Nothing should have been sent to the install endpoint.
    assert [c for c in demisto_mock._commands if c[0] == "core-api-post"] == []


def test_installed_packs_failure_is_announced():
    script, demisto_mock = load_script()

    def boom(args):
        raise RuntimeError("500 internal")

    demisto_mock._command_responses["core-api-get"] = boom
    assert script.fetch_installed_packs("") == {}

    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))
    assert "Could not read installed packs" in out
    assert "500 internal" in out


def test_diagnose_reports_pass_and_fail_per_endpoint():
    script, demisto_mock = load_script()
    script.fetch_installed_packs = lambda using: {}

    def get_ok(args):
        uri = args.get("uri", "")
        if uri.endswith("/metadata/installed"):
            return [{"Type": 1, "Contents": {"response": [{"id": "Whois", "currentVersion": "1.5.42"}]}}]
        raise RuntimeError("boom on marketplace metadata")

    def post_fail(args):
        raise RuntimeError("dependency endpoint unavailable")

    demisto_mock._command_responses["core-api-get"] = get_ok
    demisto_mock._command_responses["core-api-post"] = post_fail

    script.do_diagnose({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "installed packs" in out
    assert "pass" in out
    assert "FAIL" in out
    assert "dependency endpoint unavailable" in out


def test_diagnose_all_pass_verdict():
    script, demisto_mock = load_script()
    demisto_mock._modules = {"Core REST API_instance_1": {"brand": "Core REST API", "state": "active"}}

    def get_ok(args):
        uri = args.get("uri", "")
        if uri.endswith("/metadata/installed"):
            return [{"Type": 1, "Contents": {"response": [{"id": "Whois", "currentVersion": "1.5.42"}]}}]
        return [{"Type": 1, "Contents": {"response": {"currentVersion": "1.5.43"}}}]

    def post_ok(args):
        return [
            {
                "Type": 1,
                "Contents": {
                    "response": {
                        "packs": [
                            {
                                "id": "Whois",
                                "extras": {"pack": {"dependencies": {"Base": {"mandatory": True, "minVersion": "1.0.0"}}}},
                            }
                        ]
                    }
                },
            }
        ]

    demisto_mock._command_responses["core-api-get"] = get_ok
    demisto_mock._command_responses["core-api-post"] = post_ok

    script.do_diagnose({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    assert "All checks passed" in out
    assert "FAIL" not in out


def test_find_core_rest_api_instances_matches_known_brands():
    script, demisto_mock = load_script()
    demisto_mock._modules = {
        "Core REST API_instance_1": {"brand": "Core REST API", "state": "active"},
        "SOCFWPackManager_instance_1": {"brand": "SOCFWPackManager", "state": "active"},
        "Something": {"brand": "Whois", "state": "active"},
    }
    found = script.find_core_rest_api_instances()
    assert [f["name"] for f in found] == ["Core REST API_instance_1"]


def test_find_core_rest_api_instances_empty_when_absent():
    script, demisto_mock = load_script()
    demisto_mock._modules = {"SOCFWPackManager_instance_1": {"brand": "SOCFWPackManager", "state": "active"}}
    assert script.find_core_rest_api_instances() == []


def test_diagnose_names_missing_core_rest_api_instance_first():
    script, demisto_mock = load_script()
    demisto_mock._modules = {}

    def boom(args):
        raise RuntimeError("connection refused")

    demisto_mock._command_responses["core-api-get"] = boom
    demisto_mock._command_responses["core-api-post"] = boom

    script.do_diagnose({})
    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))

    # The missing instance is the real cause; it must be named rather than
    # leaving three opaque transport errors to interpret.
    assert "Core REST API instance" in out
    assert "none configured" in out
    assert out.index("Core REST API instance") < out.index("installed packs")


def test_dependency_failure_hint_names_missing_instance():
    script, demisto_mock = load_script()
    demisto_mock._modules = {}

    def boom(args):
        raise RuntimeError("500")

    demisto_mock._command_responses["core-api-post"] = boom
    with pytest.raises(script.DependencyLookupError):
        script.fetch_mandatory_dependencies([{"id": "CommonScripts", "version": "1.0.0"}], "")

    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))
    assert "No Core REST API instance is configured" in out


def test_dependency_failure_hint_when_instance_present():
    script, demisto_mock = load_script()
    demisto_mock._modules = {"Core REST API_instance_1": {"brand": "Core REST API", "state": "active"}}

    def boom(args):
        raise RuntimeError("404")

    demisto_mock._command_responses["core-api-post"] = boom
    with pytest.raises(script.DependencyLookupError):
        script.fetch_mandatory_dependencies([{"id": "CommonScripts", "version": "1.0.0"}], "")

    out = "\n".join(str(r.get("Contents", "")) for r in demisto_mock._results if isinstance(r, dict))
    assert "route or payload problem" in out


# ── upgrade_marketplace ───────────────────────────────────────────────────────


def test_closure_default_keeps_installed_version_and_skips_lookup():
    script, _ = load_script()
    script.fetch_mandatory_dependencies = lambda packs, using: {}

    def should_not_run(pack_id, using):
        raise AssertionError("marketplace lookup must be skipped when not upgrading")

    script.resolve_latest_version = should_not_run
    installed = {"CommonScripts": {"version": "1.22.28", "update_available": True}}
    closure = script.resolve_install_closure([{"id": "CommonScripts", "version": "latest"}], "", installed)
    assert closure == {"CommonScripts": "1.22.28"}


def test_closure_upgrade_resolves_newest_published_version():
    script, _ = load_script()
    script.fetch_mandatory_dependencies = lambda packs, using: {}
    script.resolve_latest_version = lambda pack_id, using: "1.22.39"
    installed = {"CommonScripts": {"version": "1.22.28", "update_available": True}}
    closure = script.resolve_install_closure([{"id": "CommonScripts", "version": "latest"}], "", installed, upgrade=True)
    assert closure == {"CommonScripts": "1.22.39"}


def test_closure_upgrade_skips_lookup_when_no_update_available():
    script, _ = load_script()
    script.fetch_mandatory_dependencies = lambda packs, using: {}

    def should_not_run(pack_id, using):
        raise AssertionError("no update available, so the large lookup is wasted")

    script.resolve_latest_version = should_not_run
    installed = {"Whois": {"version": "1.5.43", "update_available": False}}
    closure = script.resolve_install_closure([{"id": "Whois", "version": "latest"}], "", installed, upgrade=True)
    assert closure == {"Whois": "1.5.43"}


def test_closure_upgrade_does_not_force_upgrade_dependencies():
    script, _ = load_script()
    script.resolve_latest_version = lambda pack_id, using: "2.0.0"
    script.fetch_mandatory_dependencies = lambda packs, using: (
        {"A": {"Base": "1.0.0"}} if packs and packs[0]["id"] == "A" else {}
    )
    installed = {
        "A": {"version": "1.0.0", "update_available": True},
        "Base": {"version": "1.42.2", "update_available": True},
    }
    closure = script.resolve_install_closure([{"id": "A", "version": "latest"}], "", installed, upgrade=True)
    assert closure["A"] == "2.0.0"
    # Dependency already satisfies minVersion, so it stays put.
    assert closure["Base"] == "1.42.2"


def test_install_marketplace_packs_upgrade_produces_pending_install():
    script, demisto_mock = load_script()
    script.fetch_installed_packs = lambda using: {"CommonScripts": {"version": "1.22.28", "update_available": True}}
    script.fetch_mandatory_dependencies = lambda packs, using: {}
    script.resolve_latest_version = lambda pack_id, using: "1.22.39"
    demisto_mock._command_responses["core-api-post"] = [{"Type": 1, "Contents": {"response": []}}]

    result = script.install_marketplace_packs([{"id": "CommonScripts", "version": "latest"}], "", 0, 0, debug=False, upgrade=True)

    posts = [c for c in demisto_mock._commands if c[0] == "core-api-post"]
    assert len(posts) == 1
    body = json.loads(posts[0][1]["body"])
    assert body["packs"] == [{"id": "CommonScripts", "version": "1.22.39"}]
    assert result["installed"] == {"CommonScripts": "1.22.39"}


def test_closure_takes_highest_minversion_across_requirers():
    """Regression: Base and CommonScripts want Core >= 3.5.75 while
    CommonPlaybooks only wants 3.5.73. Taking the first value seen resolved
    Core to 3.5.73 and the platform rejected the whole install with
    'Mismatching dependency contentpack with ID Core'.
    """
    script, _ = load_script()
    graph = {
        "Base": {"Core": "3.5.75"},
        "CommonPlaybooks": {"Core": "3.5.73"},
        "CommonScripts": {"Core": "3.5.75"},
        "Core": {},
    }

    def fake_deps(packs, using):
        return {p["id"]: graph.get(p["id"], {}) for p in packs}

    script.fetch_mandatory_dependencies = fake_deps
    closure = script.resolve_install_closure(
        [
            {"id": "CommonPlaybooks", "version": "2.8.5"},
            {"id": "Base", "version": "1.42.13"},
            {"id": "CommonScripts", "version": "1.22.39"},
        ],
        "",
        {},
    )
    assert closure["Core"] == "3.5.75"


def test_closure_highest_minversion_regardless_of_requirer_order():
    script, _ = load_script()
    graph = {"A": {"Dep": "1.0.0"}, "B": {"Dep": "5.0.0"}, "Dep": {}}

    def fake_deps(packs, using):
        return {p["id"]: graph.get(p["id"], {}) for p in packs}

    script.fetch_mandatory_dependencies = fake_deps
    for seeds in (
        [{"id": "A", "version": "1.0.0"}, {"id": "B", "version": "1.0.0"}],
        [{"id": "B", "version": "1.0.0"}, {"id": "A", "version": "1.0.0"}],
    ):
        closure = script.resolve_install_closure(seeds, "", {})
        assert closure["Dep"] == "5.0.0", f"order-dependent result for {seeds}"


def test_closure_keeps_installed_version_when_it_exceeds_all_minimums():
    script, _ = load_script()
    graph = {"A": {"Dep": "1.0.0"}, "B": {"Dep": "2.0.0"}, "Dep": {}}
    script.fetch_mandatory_dependencies = lambda packs, using: {p["id"]: graph.get(p["id"], {}) for p in packs}
    installed = {"Dep": {"version": "9.9.9", "update_available": False}}
    closure = script.resolve_install_closure([{"id": "A", "version": "1.0.0"}, {"id": "B", "version": "1.0.0"}], "", installed)
    assert closure["Dep"] == "9.9.9"


def test_closure_raising_a_dependency_pulls_its_own_new_dependencies():
    script, _ = load_script()
    graph = {
        "A": {"Mid": "2.0.0"},
        # Mid only gains this dependency at 2.0.0.
        "Mid": {"Deep": "1.0.0"},
        "Deep": {},
    }
    script.fetch_mandatory_dependencies = lambda packs, using: {p["id"]: graph.get(p["id"], {}) for p in packs}
    closure = script.resolve_install_closure([{"id": "A", "version": "1.0.0"}], "", {})
    assert closure["Mid"] == "2.0.0"
    assert closure["Deep"] == "1.0.0"


def test_install_sends_ignore_warnings():
    """Regression: without ignoreWarnings a pre-existing incident field makes
    the platform reject the entire install ("Field with name 'Email
    Classification' already exists"), which is guaranteed on any tenant that
    already has content.
    """
    script, demisto_mock = load_script()
    script.fetch_installed_packs = lambda using: {}
    script.resolve_install_closure = lambda seeds, using, installed, upgrade=False: {"CommonScripts": "1.22.39"}
    demisto_mock._command_responses["core-api-post"] = [{"Type": 1, "Contents": {"response": []}}]

    script.install_marketplace_packs([{"id": "CommonScripts", "version": "latest"}], "", 0, 0, debug=False)

    posts = [c for c in demisto_mock._commands if c[0] == "core-api-post"]
    body = json.loads(posts[0][1]["body"])
    assert body["ignoreWarnings"] is True
    assert body["packs"] == [{"id": "CommonScripts", "version": "1.22.39"}]


# ── pack id / directory naming ─────────────────────────────────────────────────


def test_guess_pack_id_strips_version_and_prerelease_suffix():
    script, _ = load_script()
    cases = {
        "soc-optimization-unified.zip": "soc-optimization-unified",
        "soc-optimization-unified-v3.11.2.zip": "soc-optimization-unified",
        "soc-optimization-unified-v3.11.2": "soc-optimization-unified",
        "soc-optimization-unified-v3.11.1-pr1008.zip": "soc-optimization-unified",
        "soc-common-playbooks-unified": "soc-common-playbooks-unified",
        "SocFrameworkAbnormalSecurity-v1.0.4.zip": "SocFrameworkAbnormalSecurity",
        "": "",
    }
    for given, expected in cases.items():
        assert script._guess_pack_id_from_label(given) == expected, given


def test_guess_pack_id_leaves_embedded_dash_v_alone():
    """A naive split on "-v" truncated any pack whose own name contains it."""
    script, _ = load_script()
    assert script._guess_pack_id_from_label("soc-vendor-thing.zip") == "soc-vendor-thing"
    assert script._guess_pack_id_from_label("soc-vendor-thing-v1.2.3.zip") == "soc-vendor-thing"


# ---------------------------
# catalog URL resolution
# ---------------------------


def test_resolve_catalog_url_explicit_arg_wins():
    script, demisto_mock = load_script()
    demisto_mock._command_responses["socfw-catalog-url-get"] = [
        {"Type": 1, "Contents": {"catalog_url": "https://instance.example/catalog.json"}}
    ]

    assert script.resolve_catalog_url("https://explicit.example/catalog.json") == "https://explicit.example/catalog.json"
    # The instance is not consulted when an explicit value is supplied.
    assert not [c for c in demisto_mock._commands if c[0] == "socfw-catalog-url-get"]


def test_resolve_catalog_url_uses_instance_value():
    script, demisto_mock = load_script()
    demisto_mock._command_responses["socfw-catalog-url-get"] = [
        {"Type": 1, "Contents": {"catalog_url": "https://instance.example/catalog.json"}}
    ]

    assert script.resolve_catalog_url("") == "https://instance.example/catalog.json"


def test_resolve_catalog_url_falls_back_when_command_missing():
    script, _ = load_script()
    # No response registered: the command does not exist on this tenant.
    assert script.resolve_catalog_url("") == script.DEFAULT_CATALOG_URL


def test_resolve_catalog_url_falls_back_on_error_entry():
    script, demisto_mock = load_script()
    demisto_mock._command_responses["socfw-catalog-url-get"] = [{"Type": 4, "Contents": "Unsupported command (23)"}]

    assert script.resolve_catalog_url("") == script.DEFAULT_CATALOG_URL


# ---------------------------
# version comparison and install closure
# ---------------------------


def test_ver_key_ignores_prerelease_suffix():
    """A pre-release suffix must not inflate the patch number.

    Collecting every digit in the part folded the suffix into the number, so
    1.0.6-pr1008 keyed as (1, 0, 61008) and compared greater than 1.0.7.
    """
    script, _ = load_script()
    assert script._ver_key("1.0.6-pr1008") == (1, 0, 6)
    assert script._ver_key("1.0.6-rc1") == (1, 0, 6)
    assert script._ver_key("1.0.6") < script._ver_key("1.0.7")
    assert script._ver_key("1.0.6-pr1008") < script._ver_key("1.0.7")
    assert script._ver_key("3.11.2") == (3, 11, 2)
    assert script._ver_key("1.0") == (1, 0, 0)
    assert script._ver_key("") == (0, 0, 0)
    assert script._ver_key(None) == (0, 0, 0)


def test_ver_key_orders_double_digit_minor_correctly():
    script, _ = load_script()
    assert script._ver_key("3.9.0") < script._ver_key("3.11.0")


def test_resolve_install_closure_does_not_downgrade_an_upgrade_target(mocker):
    """A pack that is both requested and a dependency keeps its upgrade target.

    The dependency pass computes the installed version when it already
    satisfies minVersion. Writing that unconditionally overwrote the seed's
    resolved upgrade target and silently undid the upgrade.
    """
    script, _ = load_script()
    installed = {
        "PackA": {"version": "1.0.0", "update_available": True},
        "Shared": {"version": "3.0.0", "update_available": True},
    }
    mocker.patch.object(
        script, "resolve_latest_version", side_effect=lambda pid, using: {"PackA": "2.0.0", "Shared": "4.0.0"}[pid]
    )
    # PackA depends on Shared at a minimum far below what is installed.
    mocker.patch.object(
        script,
        "fetch_mandatory_dependencies",
        side_effect=[{"PackA": {"Shared": "1.0.0"}}, {}],
    )

    wanted = script.resolve_install_closure(
        [{"id": "PackA", "version": "latest"}, {"id": "Shared", "version": "latest"}],
        using="",
        installed=installed,
        upgrade=True,
    )

    assert wanted["PackA"] == "2.0.0"
    # Shared was requested for upgrade to 4.0.0; the dependency pass must not
    # drag it back to the installed 3.0.0.
    assert wanted["Shared"] == "4.0.0"


def test_resolve_install_closure_still_raises_a_dependency_to_min_version(mocker):
    script, _ = load_script()
    installed = {"PackA": {"version": "1.0.0"}, "Dep": {"version": "1.0.0"}}
    mocker.patch.object(script, "resolve_latest_version", side_effect=lambda pid, using: "1.0.0")
    mocker.patch.object(
        script,
        "fetch_mandatory_dependencies",
        side_effect=[{"PackA": {"Dep": "2.5.0"}}, {}],
    )

    wanted = script.resolve_install_closure([{"id": "PackA", "version": "latest"}], using="", installed=installed, upgrade=False)

    assert wanted["Dep"] == "2.5.0"