GoogleSecOpsSyncCaseInformation

Syncs the latest case information, related alerts and alert entities from Google SecOps and updates the Cortex XSOAR incident data.

python · Google SecOps

Details

IDGoogleSecOpsSyncCaseInformation
Languagepython
From Version6.10.0
Docker Imagedemisto/python3:3.12.13.10404775

README

Syncs the latest case information, related alerts and alert entities from Google SecOps and updates the Cortex XSOAR incident data.

Script Data


Name Description
Script Type python3
Cortex XSOAR Version 6.10.0

Inputs


Argument Name Description
case_id Specify the ID of the Google SecOps Case to sync.

Note: If not provided, the script will try to retrieve it from the incident context.
alert_page_size Specify the maximum number of alerts to retrieve.

Note: Maximum value is 1000.
entity_page_size Specify the maximum number of entities to retrieve per alert.

Note: Maximum value is 1000.

Outputs


There are no outputs for this script.

"""Test File for GoogleSecOpsSyncCaseInformation Script."""

import json
from pathlib import Path
from unittest.mock import MagicMock, patch

import demistomock as demisto
import GoogleSecOpsSyncCaseInformation
import pytest
from GoogleSecOpsSyncCaseInformation import (
    ALERT_LOOP_TIMEOUT_SECONDS,
    ERROR_MESSAGES,
    MAX_PAGE_SIZE,
    apply_mapper,
    epoch_ms_to_datestring,
    epoch_ms_to_time_delta,
    execute_command_safe,
    get_alert_entity_list,
    get_alert_list,
    get_case_information,
    get_command_result,
    prepare_alert_sla,
    sync_case_information,
)

CASE_ID = "1001"

TEST_DATA_DIR = Path(__file__).parent


def util_load_json(path):
    """Load file in JSON format."""
    with open(TEST_DATA_DIR / path, encoding="utf-8") as f:
        return json.loads(f.read())


@pytest.fixture
def mock_case_raw_response():
    return util_load_json("test_data/case_get_response.json")


@pytest.fixture
def mock_alert_data():
    return util_load_json("test_data/case_alert_list_response.json")


@pytest.fixture
def mock_entity_data():
    return util_load_json("test_data/case_alert_entity_list_response.json")


@pytest.fixture
def mock_set_incident_data():
    return util_load_json("test_data/set_incident_mapper_response.json")


def _success_result(contents):
    return {"Type": 1, "Contents": contents, "ContentsFormat": "json"}


def _error_result(msg):
    return {"Type": 4, "Contents": msg, "ContentsFormat": "text"}


def test_epoch_ms_to_datestring():
    assert epoch_ms_to_datestring("1778778979232") == "2026-05-14 17:16:19 UTC"


def test_epoch_ms_to_time_delta():
    ms = (2 * 86400 + 3 * 3600 + 4 * 60 + 5) * 1000
    assert epoch_ms_to_time_delta(ms) == "2 days, 3 hours, 4 minutes, 5 seconds"


def test_prepare_alert_sla():
    sla = {
        "expirationStatus": "OPEN_SLA",
        "expirationTime": "1780832620238",
        "criticalExpirationTime": "1780580620238",
        "remainingTimeSinceLastPause": 3600000,
    }
    expected = (
        "Status - OPEN_SLA\n"
        "Expiration Time - 2026-06-07 11:43:40 UTC\n"
        "Critical Expiration Time - 2026-06-04 13:43:40 UTC\n"
        "Remaining Time Since Last Pause - 1 hours"
    )
    assert prepare_alert_sla(sla) == expected


def test_get_command_result_returns_first_non_error():
    """get_command_result should skip error entries and return the first success."""
    err = _error_result("bad")
    ok = _success_result({"key": "val"})
    assert get_command_result([err, ok]) == ok


def test_get_command_result_all_errors_returns_empty():
    """get_command_result with all error entries should return empty dict."""
    assert get_command_result([_error_result("e1"), _error_result("e2")]) == {}


def test_get_command_result_empty_list_returns_empty():
    assert get_command_result([]) == {}


def test_apply_mapper_normalises_keys(mocker):
    """apply_mapper should return lowercase no-space keys."""
    mocker.patch.object(demisto, "mapObject", return_value={"Google SecOps Status": "OPEN", "Display Name": "Case 1"})
    result = apply_mapper({})
    assert "googlesecopsstatus" in result
    assert "displayname" in result


def test_apply_mapper_empty_mapping(mocker):
    mocker.patch.object(demisto, "mapObject", return_value={})
    assert apply_mapper({}) == {}


def test_execute_command_safe_success(mocker):
    ok = _success_result({"data": 1})
    mocker.patch.object(demisto, "executeCommand", return_value=[ok])
    result, err = execute_command_safe("gcb-case-get", {"case_id": "1"})
    assert result == ok
    assert err is None


def test_execute_command_safe_error(mocker):
    mocker.patch.object(demisto, "executeCommand", return_value=[_error_result("not found")])
    result, err = execute_command_safe("gcb-case-get", {"case_id": "999"})
    assert result == {}
    assert err == "not found"


def test_execute_command_safe_wraps_non_list(mocker):
    ok = _success_result({"data": 1})
    mocker.patch.object(demisto, "executeCommand", return_value=ok)
    result, err = execute_command_safe("gcb-case-get", {"case_id": "1"})
    assert result == ok
    assert err is None


def test_execute_command_safe_empty_raw_list(mocker):
    """executeCommand returning [] should yield ({}, 'Unknown error')."""
    mocker.patch.object(demisto, "executeCommand", return_value=[])
    result, err = execute_command_safe("gcb-case-get", {"case_id": "1"})
    assert result == {}
    assert err == "Unknown error"


def test_get_case_information_success(mocker, mock_case_raw_response):
    """get_case_information returns (raw_result, case_data) tuple."""
    ok = _success_result(mock_case_raw_response)
    mocker.patch.object(demisto, "executeCommand", return_value=[ok])
    raw_result, case_data = get_case_information(CASE_ID)
    assert raw_result == ok
    assert case_data == mock_case_raw_response


def test_get_case_information_raises_on_error(mocker):
    """get_case_information should raise ValueError on command failure."""
    mocker.patch.object(demisto, "executeCommand", return_value=[_error_result("not found")])
    with pytest.raises(ValueError, match="gcb-case-get"):
        get_case_information(CASE_ID)


def test_get_alerts_success(mocker, mock_alert_data):
    """get_alert_list returns (raw_result, alerts) tuple."""
    ok = _success_result(mock_alert_data)
    mocker.patch.object(demisto, "executeCommand", return_value=[ok])
    raw_result, alerts = get_alert_list(CASE_ID, 1000)
    assert raw_result == ok
    assert len(alerts) == 2
    assert alerts[0]["displayName"] == "TEST ALERT"
    assert alerts[1]["displayName"] == "TEST ALERT 2"


def test_get_alerts_raises_on_error(mocker):
    """get_alert_list should raise ValueError on command failure."""
    mocker.patch.object(demisto, "executeCommand", return_value=[_error_result("alerts not found")])
    with pytest.raises(ValueError, match="alerts"):
        get_alert_list(CASE_ID, 1000)


def test_get_alert_entities_success(mocker, mock_entity_data):
    """get_alert_entity_list returns (raw_result, entities) tuple on success."""
    ok = _success_result(mock_entity_data)
    mocker.patch.object(demisto, "executeCommand", return_value=[ok])
    raw_result, entities = get_alert_entity_list(CASE_ID, "1000001", 1000)
    assert raw_result == ok
    assert len(entities) == 2
    assert entities[0]["identifier"] == "0.0.0.1"
    assert entities[1]["identifier"] == "demo.com"


def test_get_alert_entities_returns_none_on_error(mocker):
    """get_alert_entity_list should return (None, None) and call debug on failure."""
    mocker.patch.object(demisto, "executeCommand", return_value=[_error_result("entity error")])
    debug_mock = mocker.patch.object(demisto, "debug")
    raw_result, entities = get_alert_entity_list(CASE_ID, "1000001", 1000)
    assert raw_result is None
    assert entities is None
    assert debug_mock.called


def test_sync_case_information_raises_when_no_case_id(mocker):
    """sync_case_information should raise ValueError with full error message when case_id is missing."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    expected_msg = ERROR_MESSAGES["MISSING_ARGUMENT"].format("case_id")
    with pytest.raises(ValueError, match=expected_msg):
        sync_case_information({})


def test_sync_case_information_reads_case_id_from_incident(mocker, mock_case_raw_response):
    """When case_id arg absent, case ID read from incident custom field."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {"googlesecopscaseid": CASE_ID}})
    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation,
        "get_case_information",
        return_value=(_success_result(mock_case_raw_response), mock_case_raw_response.copy()),
    )
    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(_success_result({"caseAlerts": [], "totalSize": 0}), [])
    )
    mocker.patch.object(demisto, "mapObject", return_value={})

    results = sync_case_information({})
    assert results


def test_sync_case_information_success(mocker, mock_case_raw_response, mock_alert_data, mock_entity_data):
    """
    sync_case_information should return case result + alert result +
    entity results per alert + final CommandResults.
    """
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value={"googlesecopsstatus": "OPEN"})

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result(mock_alert_data)
    entity_ok = _success_result(mock_entity_data)

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, mock_alert_data["caseAlerts"]))
    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_alert_entity_list", return_value=(entity_ok, mock_entity_data["involvedEntities"])
    )
    mocker.patch.object(demisto, "executeCommand", return_value=[_success_result({})])

    results = sync_case_information({"case_id": CASE_ID})
    assert len(results) == 5


def test_sync_case_information_calls_setincident(
    mocker, mock_case_raw_response, mock_alert_data, mock_entity_data, mock_set_incident_data
):
    """sync_case_information should call setIncident with full mapped case + alert + entity data."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value=mock_set_incident_data)

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result(mock_alert_data)
    entity_ok = _success_result(mock_entity_data)

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, mock_alert_data["caseAlerts"]))
    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_alert_entity_list", return_value=(entity_ok, mock_entity_data["involvedEntities"])
    )
    exec_mock = mocker.patch.object(demisto, "executeCommand", return_value=[_success_result({})])

    sync_case_information({"case_id": CASE_ID})

    calls = [(c.args[0], c.args[1]) for c in exec_mock.call_args_list]
    set_incident_calls = [args for cmd, args in calls if cmd == "setIncident"]
    assert len(set_incident_calls) == 1
    assert set_incident_calls[0] == mock_set_incident_data


def test_sync_case_information_no_setincident_when_mapper_empty(mocker, mock_case_raw_response):
    """setIncident should NOT be called when mapper returns no data."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value={})

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result({"caseAlerts": [], "totalSize": 0})

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, []))
    exec_mock = mocker.patch.object(demisto, "executeCommand")

    sync_case_information({"case_id": CASE_ID})

    if exec_mock.called:
        calls = [c.args[0] for c in exec_mock.call_args_list]
        assert "setIncident" not in calls


def test_sync_case_information_default_page_sizes(mocker, mock_case_raw_response):
    """Alert page_size should default to 1000."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value={})

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result({"caseAlerts": [], "totalSize": 0})

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    get_alerts_mock = mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, []))

    sync_case_information({"case_id": CASE_ID})

    page_size_used = get_alerts_mock.call_args[0][1]
    assert page_size_used == 1000


@pytest.mark.parametrize("alert_page_size,entity_page_size", [("100", "200"), ("1", "1"), ("1000", "1000")])
def test_sync_case_information_custom_page_sizes(
    mocker, mock_case_raw_response, mock_alert_data, mock_entity_data, alert_page_size, entity_page_size
):
    """Custom page sizes, including the inclusive boundaries 1 and MAX_PAGE_SIZE, should be forwarded as-is."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value={})

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result(mock_alert_data)
    entity_ok = _success_result(mock_entity_data)

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    get_alerts_mock = mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, mock_alert_data["caseAlerts"])
    )
    get_entities_mock = mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_alert_entity_list", return_value=(entity_ok, mock_entity_data["involvedEntities"])
    )

    sync_case_information({"case_id": CASE_ID, "alert_page_size": alert_page_size, "entity_page_size": entity_page_size})

    assert get_alerts_mock.call_args[0][1] == int(alert_page_size)
    assert get_entities_mock.call_args[0][2] == int(entity_page_size)


@pytest.mark.parametrize(
    "args,expected_error_message",
    [
        (
            {},
            ERROR_MESSAGES["MISSING_ARGUMENT"].format("case_id"),
        ),
        (
            {"case_id": CASE_ID, "alert_page_size": "0"},
            ERROR_MESSAGES["INVALID_INT_RANGE"].format(0, "alert_page_size", 1, MAX_PAGE_SIZE),
        ),
        (
            {"case_id": CASE_ID, "entity_page_size": "-1"},
            ERROR_MESSAGES["INVALID_INT_RANGE"].format(-1, "entity_page_size", 1, MAX_PAGE_SIZE),
        ),
        (
            {"case_id": CASE_ID, "alert_page_size": "1001"},
            ERROR_MESSAGES["INVALID_INT_RANGE"].format(1001, "alert_page_size", 1, MAX_PAGE_SIZE),
        ),
        (
            {"case_id": CASE_ID, "entity_page_size": "5000"},
            ERROR_MESSAGES["INVALID_INT_RANGE"].format(5000, "entity_page_size", 1, MAX_PAGE_SIZE),
        ),
    ],
)
def test_sync_case_information_invalid_args(mocker, args, expected_error_message):
    """Test sync_case_information raises ValueError for invalid argument values."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})

    with pytest.raises(ValueError) as error:
        sync_case_information(args)

    assert str(error.value) == expected_error_message


def test_sync_case_information_raises_on_case_get_failure(mocker):
    """gcb-case-get failure should raise ValueError."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_case_information", side_effect=ValueError("gcb-case-get failed"))
    with pytest.raises(ValueError, match="gcb-case-get"):
        sync_case_information({"case_id": CASE_ID})


def test_sync_case_information_raises_on_alert_list_failure(mocker, mock_case_raw_response):
    """gcb-case-alert-list failure should raise ValueError."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})

    case_ok = _success_result(mock_case_raw_response)
    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_list", side_effect=ValueError("Failed to retrieve alerts"))

    with pytest.raises(ValueError, match="alerts"):
        sync_case_information({"case_id": CASE_ID})


def test_sync_case_information_skips_entity_on_failure(mocker, mock_case_raw_response, mock_alert_data):
    """Entity fetch failures should be skipped; no exception raised."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value={})

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result(mock_alert_data)

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, mock_alert_data["caseAlerts"]))
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_entity_list", return_value=(None, None))

    results = sync_case_information({"case_id": CASE_ID})

    assert any(hasattr(r, "readable_output") for r in results)


def test_sync_case_information_skips_alert_with_no_id(mocker, mock_case_raw_response):
    """Alerts without alertId or name should be silently skipped."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value={})

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result({"caseAlerts": [{"displayName": "No ID alert"}], "totalSize": 1})

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, [{"displayName": "No ID alert"}])
    )
    entities_mock = mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_entity_list")

    sync_case_information({"case_id": CASE_ID})

    entities_mock.assert_not_called()


def test_main_success(mocker):
    """main should call return_results with sync_case_information output."""
    mocker.patch.object(demisto, "args", return_value={"case_id": CASE_ID})
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "sync_case_information", return_value=[MagicMock()])
    mock_return_results = mocker.patch.object(GoogleSecOpsSyncCaseInformation, "return_results")
    GoogleSecOpsSyncCaseInformation.main()
    assert mock_return_results.called


def test_main_calls_return_error_on_exception(mocker):
    """main should call return_error when sync_case_information raises."""
    mocker.patch.object(demisto, "args", return_value={})
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "sync_case_information", side_effect=Exception("boom"))
    mocker.patch.object(demisto, "error")
    mock_return_error = mocker.patch.object(GoogleSecOpsSyncCaseInformation, "return_error")
    GoogleSecOpsSyncCaseInformation.main()
    mock_return_error.assert_called_once()
    assert "boom" in mock_return_error.call_args[0][0]


def test_sync_case_information_breaks_loop_on_timeout(mocker, mock_case_raw_response, mock_alert_data, mock_entity_data):
    """When elapsed time exceeds ALERT_LOOP_TIMEOUT_SECONDS, loop breaks and remaining alerts are skipped."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value={})

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result(mock_alert_data)
    entity_ok = _success_result(mock_entity_data)

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, mock_alert_data["caseAlerts"]))
    get_entities_mock = mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_alert_entity_list", return_value=(entity_ok, mock_entity_data["involvedEntities"])
    )
    debug_mock = mocker.patch.object(demisto, "debug")

    # First call returns loop_start, second call returns past threshold — triggers break on first alert
    with patch("GoogleSecOpsSyncCaseInformation.time.time", side_effect=[0, ALERT_LOOP_TIMEOUT_SECONDS + 1]):
        sync_case_information({"case_id": CASE_ID})

    get_entities_mock.assert_not_called()
    debug_calls = [str(c) for c in debug_mock.call_args_list]
    assert any("9 minutes" in c for c in debug_calls)


def test_sync_case_information_no_timeout_when_fast(mocker, mock_case_raw_response, mock_alert_data, mock_entity_data):
    """When loop finishes within timeout, all alerts are processed normally."""
    mocker.patch.object(demisto, "incident", return_value={"CustomFields": {}})
    mocker.patch.object(demisto, "mapObject", return_value={})

    case_ok = _success_result(mock_case_raw_response)
    alert_ok = _success_result(mock_alert_data)
    entity_ok = _success_result(mock_entity_data)

    mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_case_information", return_value=(case_ok, mock_case_raw_response.copy())
    )
    mocker.patch.object(GoogleSecOpsSyncCaseInformation, "get_alert_list", return_value=(alert_ok, mock_alert_data["caseAlerts"]))
    get_entities_mock = mocker.patch.object(
        GoogleSecOpsSyncCaseInformation, "get_alert_entity_list", return_value=(entity_ok, mock_entity_data["involvedEntities"])
    )

    alert_count = len(mock_alert_data["caseAlerts"])
    # All time.time() calls return 0 — never exceeds threshold
    with patch("GoogleSecOpsSyncCaseInformation.time.time", return_value=0):
        sync_case_information({"case_id": CASE_ID})

    assert get_entities_mock.call_count == alert_count