Slack Event Collector

Slack logs event collector integration for XSIAM.

Analytics & SIEM · Slack

Details

IDSlack Event Collector
ProviderSlack
CategoryAnalytics & SIEM
From Version6.8.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix Cortex Cloud Cloud Runtime Security Cloud Posture Security XSIAM EDR

README

Slack logs event collector integration for XSIAM.
This integration was integrated and tested with version v1 of Slack Audit Logs API.

This is the default integration for this content pack when configured by the Data Onboarder in Cortex XSIAM.

Configure Slack Event Collector in Cortex

Parameter Description Required
Server URL   True
User Token   True
The maximum number of audit logs to fetch   False
First fetch time interval Data is not available prior to March 2018. 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.

slack-get-events


Gets audit log events from Slack.

Base Command

slack-get-events

Input

Argument Name Description Required
should_push_events If true, the command will create events, otherwise it will only display them. Possible values are: true, false. Default is false. Required
limit Number of results to return, maximum is 9999. Default is 10. Optional
oldest Occurrence time of the least recent audit event to include (inclusive). Data is not available prior to March 2018. Default is 3 days. Optional
latest Occurrence time of the most recent audit event to include (inclusive). Optional
action Name of the action. Optional
actor ID of the user who initiated the action. Optional
entity ID of the target entity of the action (such as a channel, workspace, organization, file). Optional

Context Output

There is no context output for this command.

Command Example

!slack-get-events action=user_login oldest="15 months ago" limit=1

Context Example

{
    "SlackEvents": [
      {
         "id":"0123a45b-6c7d-8900-e12f-3456789gh0i1",
         "date_create":1521214343,
         "action":"user_login",
         "actor":{
            "type":"user",
            "user":{
               "id":"W123AB456",
               "name":"Charlie Parker",
               "email":"bird@slack.com"
            }
         },
         "entity":{
            "type":"user",
            "user":{
               "id":"W123AB456",
               "name":"Charlie Parker",
               "email":"bird@slack.com"
            }
         },
         "context":{
            "location":{
               "type":"enterprise",
               "id":"E1701NCCA",
               "name":"Birdland",
               "domain":"birdland"
            },
            "ua":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/64.0.3282.186 Safari\/537.36",
            "session_id": "847288190092",
            "ip_address":"1.23.45.678"
        }
      }
    ]
}

Human Readable Output

Slack Audit Logs

action actor context date_create entity id
user_login type: user
user: {“id”: “W123AB456”, “name”: “Charlie Parker”, “email”: “bird@slack.com”}
location: {“type”: “enterprise”, “id”: “E1701NCCA”, “name”: “Birdland”, “domain”: “birdland”}
ua: Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/64.0.3282.186 Safari\/537.36
session_id: 847288190092
ip_address: 1.23.45.678
1970-01-18 14:33:34 type: user
user: {“id”: “W123AB456”, “name”: “Charlie Parker”, “email”: “bird@slack.com”}
0123a45b-6c7d-8900-e12f-3456789gh0i2

Configuration parameters

  • url — Server URL (required)
  • user_token — (required)
  • isFetchEvents — Fetch Events
  • eventFetchInterval — Events Fetch Interval
  • limit — The maximum number of audit logs to fetch
  • oldest — First fetch time interval
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (1)

  • slack-get-events

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

import json
from copy import deepcopy

import demistomock as demisto
import pytest
from CommonServerPython import DemistoException
from requests import Session
from SlackEventCollector import Client

""" Helpers """


def util_load_json(path):
    with open(path, encoding="utf-8") as f:
        return json.loads(f.read())


MOCK_ENTRY = util_load_json("test_data/mock_event.json")


class MockResponse:
    def __init__(self, data: list):
        self.ok = True
        self.status_code = 200
        self.data = {"entries": [self.create_mock_entry(**e) for e in data]}

    def create_mock_entry(self, **kwargs) -> dict:
        return deepcopy(MOCK_ENTRY) | kwargs

    def json(self):
        return self.data

    def raise_for_status(self):
        pass


def make_page(events: list, next_cursor: str | None = None) -> dict:
    """Builds a raw Slack /logs API page (dict) from a list of {id, date_create} events."""
    page = MockResponse(events).data
    if next_cursor:
        page["response_metadata"] = {"next_cursor": next_cursor}
    return page


""" Test methods """


def test_update_last_run_keeps_boundary_ids_at_window_end_when_draining():
    """
    Given:
        - A drained window still below 'now' (reached_limit=False, caught_up=False) whose sent
          events include one recorded EXACTLY at window_end (Slack's `latest` is inclusive).
    When:
        - Updating last_run for that window.
    Then:
        - last_fetched_time advances to window_end AND last_fetched_ids keeps the id(s) at
          window_end, so the next run (which re-queries from window_end, inclusive) dedups the
          boundary event instead of re-ingesting it. (Regression: previously ids were cleared,
          causing duplicate ingestion for events at window_end.)
    """
    from SlackEventCollector import update_last_run

    last_run: dict = {"last_fetched_time": 0, "last_fetched_ids": []}
    sent_events = [
        {"id": "a", "date_create": 50},
        {"id": "b", "date_create": 100},  # exactly at window_end
    ]
    update_last_run(last_run, sent_events, window_end=100, reached_limit=False, caught_up=False)

    assert last_run["last_fetched_time"] == 100
    assert last_run["last_fetched_ids"] == ["b"]  # boundary id retained, NOT cleared


def test_update_last_run_drained_window_no_event_at_boundary_has_empty_ids():
    """
    Given:
        - A drained window still below 'now' whose sent events are all strictly before window_end.
    When:
        - Updating last_run.
    Then:
        - last_fetched_time advances to window_end and last_fetched_ids is empty (nothing sits on
          the inclusive boundary, so there is nothing to dedup next run).
    """
    from SlackEventCollector import update_last_run

    last_run: dict = {"last_fetched_time": 0, "last_fetched_ids": []}
    sent_events = [{"id": "a", "date_create": 50}, {"id": "b", "date_create": 90}]
    update_last_run(last_run, sent_events, window_end=100, reached_limit=False, caught_up=False)

    assert last_run["last_fetched_time"] == 100
    assert last_run["last_fetched_ids"] == []


def test_compute_window_start_first_fetch_defaults_to_config_lookback():
    """
    Given:
        - The FIRST fetch: empty last_run and NO 'oldest' configured.
    When:
        - Computing the window start.
    Then:
        - It falls back to Config.DEFAULT_FIRST_FETCH ("10 minutes ago") - roughly 600 seconds
          of lookback from the current time - NOT exactly 'now', so events around startup are
          not missed. (The default is parsed relative to the real clock, so a small tolerance
          is allowed.)
    """
    import time as _time

    from SlackEventCollector import Config, compute_window_start

    now = 1_000_000  # arbitrary; the default lookback is parsed relative to the real clock
    before = int(_time.time())
    window_start = compute_window_start(params={}, last_run={}, now=now)
    after = int(_time.time())

    assert Config.DEFAULT_FIRST_FETCH == "10 minutes ago"
    # window_start should be ~10 minutes before "now"; allow a couple seconds of clock drift.
    assert before - 600 - 2 <= window_start <= after - 600 + 2


def test_compute_window_start_explicit_oldest_wins_over_default():
    """
    Given:
        - The FIRST fetch (empty last_run) but an explicit 'oldest' IS configured.
    When:
        - Computing the window start.
    Then:
        - The explicit 'oldest' takes precedence over the default first-fetch lookback.
    """
    from SlackEventCollector import compute_window_start

    now = 1_000_000
    window_start = compute_window_start(params={"oldest": "500"}, last_run={}, now=now)

    assert window_start == 500


def test_compute_window_start_resumes_from_last_fetched_time():
    """
    Given:
        - A subsequent fetch where last_run holds a 'last_fetched_time'.
    When:
        - Computing the window start.
    Then:
        - It resumes from the stored 'last_fetched_time', ignoring both 'oldest' and the default.
    """
    from SlackEventCollector import compute_window_start

    now = 1_000_000
    window_start = compute_window_start(params={"oldest": "500"}, last_run={"last_fetched_time": 750}, now=now)

    assert window_start == 750


def test_test_module(mocker):
    """
    Given:
        - test-module call
    When:
        - A response with an OK status_code is retrieved from the API call.
    Then:
        - Make sure 'ok' is returned and only a single, lightweight credentials-check
          API call is made (NOT a full fetch loop).
    """
    from SlackEventCollector import test_module_command

    request = mocker.patch.object(Session, "request", return_value=MockResponse([]))
    assert test_module_command(Client(base_url=""), {}) == "ok"
    assert request.call_count == 1


def test_get_events_runs_fetch_cycle_oldest_first(mocker):
    """
    Given:
        - slack-get-events call (manual command).
        - Three results retrieved from the API (newest-first).
    When:
        - Running the command, which reuses the same fetch cycle as the collector.
    Then:
        - All events are returned as part of the CommandResult, sorted oldest-first.
    """
    from SlackEventCollector import get_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(demisto, "getLastRun", return_value={})
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "3", "date_create": 300},
                {"id": "2", "date_create": 200},
                {"id": "1", "date_create": 100},
            ]
        ),
    )
    events, results = get_events_command(Client(base_url=""), args={"limit": 10, "oldest": "100"})

    assert [e["id"] for e in events] == ["1", "2", "3"]  # oldest-first
    assert len(results.raw_response.get("entries", [])) == 3


def test_get_events_has_no_side_effects_on_collector_state(mocker):
    """
    Given:
        - slack-get-events call while a lastRun already exists on the instance.
    When:
        - Running the manual command (which internally runs a collection cycle).
    Then:
        - The command persists nothing (demisto.setLastRun is never called) and runs from a fresh
          state, so it has no side effects on the collector's persisted state; the object returned
          by getLastRun stays exactly as it was.
    """
    from SlackEventCollector import get_events_command

    persisted_last_run = {"last_fetched_time": 50, "last_fetched_ids": ["old"]}
    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    get_last_run = mocker.patch.object(demisto, "getLastRun", return_value=persisted_last_run)
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "3", "date_create": 300},
                {"id": "2", "date_create": 200},
            ]
        ),
    )
    get_events_command(Client(base_url=""), args={"limit": 10, "oldest": "100"})

    # the manual command must never persist progress
    set_last_run.assert_not_called()
    # the persisted state stays exactly as it was
    assert get_last_run.return_value == {"last_fetched_time": 50, "last_fetched_ids": ["old"]}


""" fetch-events (forward-window algorithm) """


def test_first_fetch_sends_oldest_first_within_timeframe(mocker):
    """
    Given:
        - The FIRST fetch (empty last_run), first-fetch 'oldest' = 100, and 'now' = 1000.
        - The API returns events newest-first within the window.
    When:
        - Running the fetch and the number of events is below the limit and the window reaches 'now'.
    Then:
        - Even on the first fetch, the returned events are sorted OLDEST-first (ascending by date_create),
          i.e. the oldest events in the queried timeframe are sent to XSIAM first.
        - last_run advances 'last_fetched_time' to the newest event time (caught up, no resume state).
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "3", "date_create": 300},
                {"id": "2", "date_create": 200},
                {"id": "1", "date_create": 100},
            ]
        ),
    )
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10, "oldest": "100"},
        last_run={},
    )

    assert [e["id"] for e in events] == ["1", "2", "3"]  # oldest-first
    assert last_run.get("last_fetched_time") == 300
    assert last_run.get("last_fetched_ids") == ["3"]


def test_first_fetch_over_limit_sends_oldest_then_resumes(mocker):
    """
    Given:
        - The FIRST fetch (empty last_run) with more events in the window than the limit.
        - limit = 2, oldest = 100, now = 1000.
    When:
        - Sorting ascending and sending only the oldest 'limit' events.
    Then:
        - On the first fetch the OLDEST 2 events (not the newest) are sent to XSIAM.
        - last_fetched_time / last_fetched_ids point at the last SENT event (time=200, id="2"),
          so the next run resumes mid-window and continues forward.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "4", "date_create": 400},
                {"id": "3", "date_create": 300},
                {"id": "2", "date_create": 200},
                {"id": "1", "date_create": 100},
            ]
        ),
    )
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 2, "oldest": "100"},
        last_run={},
    )

    assert [e["id"] for e in events] == ["1", "2"]  # oldest two, NOT ["3", "4"]
    assert last_run.get("last_fetched_time") == 200
    assert last_run.get("last_fetched_ids") == ["2"]


def test_fetch_events_caps_each_window_to_max_fetch_window(mocker):
    """
    Given:
        - last_fetched_time = 100, DEFAULT_MAX_FETCH_WINDOW patched to 50 seconds, now = 200.
        - The first window is therefore [100, 150] (capped by max_window, NOT jumping to now).
        - The first window returns two events; later windows are empty.
    When:
        - Running a fetch (which walks forward window-by-window until 'now').
    Then:
        - The FIRST API call uses the capped upper boundary (150), proving no single request
          ever spans more than max_window seconds (timeout / OOM protection).
        - The two events are returned oldest-first.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=200)
    mocker.patch("SlackEventCollector.Config.DEFAULT_MAX_FETCH_WINDOW", 50)
    window_with_events = make_page(
        [
            {"id": "b", "date_create": 140},
            {"id": "a", "date_create": 120},
        ]
    )
    empty = make_page([])
    http = mocker.patch.object(Client, "_http_request", side_effect=[window_with_events, empty])
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10},
        last_run={"last_fetched_time": 100, "last_fetched_ids": []},
    )

    # The FIRST request's upper bound is the cap (150), not now (200): each window is bounded.
    first_call_params = http.call_args_list[0].kwargs["params"]
    assert first_call_params["oldest"] == 100
    assert first_call_params["latest"] == 150

    assert [e["id"] for e in events] == ["a", "b"]


def test_fetch_events_dedups_boundary_ids(mocker):
    """
    Given:
        - last_fetched_time = 200 and last_fetched_ids = ["2"] (already sent on a previous run).
        - Slack 'oldest' is inclusive, so the API returns id="2"/date=200 again.
    When:
        - Fetching the next window.
    Then:
        - The already-sent boundary event ("2") is filtered out.
        - Only the genuinely new events are returned, oldest-first.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "4", "date_create": 400},
                {"id": "3", "date_create": 300},
                {"id": "2", "date_create": 200},  # already fetched boundary event
            ]
        ),
    )
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10},
        last_run={"last_fetched_time": 200, "last_fetched_ids": ["2"]},
    )

    assert [e["id"] for e in events] == ["3", "4"]  # "2" deduped
    assert last_run.get("last_fetched_time") == 400
    assert last_run.get("last_fetched_ids") == ["4"]


def test_fetch_events_paginates_within_window(mocker):
    """
    Given:
        - A single window whose events span two cursor-paginated API pages.
        - limit is high enough to send all of them.
    When:
        - Fetching all events in the window.
    Then:
        - Both pages are requested (cursor pagination).
        - All events are aggregated and returned oldest-first.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    page1 = make_page(
        [
            {"id": "6", "date_create": 600},
            {"id": "5", "date_create": 500},
            {"id": "4", "date_create": 400},
        ],
        next_cursor="page2",
    )
    page2 = make_page(
        [
            {"id": "3", "date_create": 300},
            {"id": "2", "date_create": 200},
            {"id": "1", "date_create": 100},
        ]
    )
    http = mocker.patch.object(Client, "_http_request", side_effect=[page1, page2])
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 100, "oldest": "100"},
        last_run={},
    )

    assert http.call_count == 2
    assert [e["id"] for e in events] == ["1", "2", "3", "4", "5", "6"]
    assert last_run.get("last_fetched_time") == 600


def test_fetch_events_groups_boundary_ids_at_same_timestamp(mocker):
    """
    Given:
        - The limit falls exactly between two events that share the same date_create.
        - limit = 3; events at time 200 have ids "2a" and "2b".
    When:
        - Sending the oldest 'limit' events (id=1@100, id=2a@200, id=2b@200).
    Then:
        - last_fetched_time = 200 and last_fetched_ids contains BOTH ids at time 200,
          so neither is re-sent next run.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "3", "date_create": 300},
                {"id": "2b", "date_create": 200},
                {"id": "2a", "date_create": 200},
                {"id": "1", "date_create": 100},
            ]
        ),
    )
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 3, "oldest": "100"},
        last_run={},
    )

    assert [e["id"] for e in events] == ["1", "2a", "2b"]
    assert last_run.get("last_fetched_time") == 200
    assert sorted(last_run.get("last_fetched_ids")) == ["2a", "2b"]


def test_fetch_events_stops_on_first_empty_window(mocker):
    """
    Given:
        - A backlog capped well below 'now' (still backfilling).
        - last_fetched_time = 100, DEFAULT_MAX_FETCH_WINDOW = 50, now = 1000.
        - MAX_WINDOWS_PER_RUN patched high enough that the per-run cap is NOT the limiting factor.
    When:
        - The very first window ([100, 150]) returns no events.
    Then:
        - The run stops walking as soon as a window returns zero events (only ONE API call),
          instead of crawling forward across further empty windows. The mark still advances to
          that first window's end (150) so the next scheduled run resumes from there, and no
          events are returned.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch("SlackEventCollector.Config.DEFAULT_MAX_FETCH_WINDOW", 50)
    mocker.patch("SlackEventCollector.Config.MAX_WINDOWS_PER_RUN", 1000)
    http = mocker.patch.object(Client, "_http_request", return_value=make_page([]))
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10},
        last_run={"last_fetched_time": 100, "last_fetched_ids": ["x"]},
    )

    assert events == []
    assert http.call_count == 1  # stopped immediately on the empty response
    assert last_run.get("last_fetched_time") == 150  # advanced to the first window's end


def test_fetch_events_empty_window_when_caught_up_keeps_state(mocker):
    """
    Given:
        - An empty window whose upper bound reaches 'now' (caught up, steady state).
        - last_fetched_time = 990, large max window, now = 1000 -> window [990, 1000].
    When:
        - The API returns no events.
    Then:
        - The boundary state is preserved (last_fetched_time not pushed to 'now' with no events)
          so that no events recorded exactly at the boundary could be skipped.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(Client, "_http_request", return_value=make_page([]))
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10},
        last_run={"last_fetched_time": 990, "last_fetched_ids": ["x"]},
    )

    assert events == []
    assert last_run.get("last_fetched_time") == 990
    assert last_run.get("last_fetched_ids") == ["x"]


def test_fetch_events_merges_boundary_ids_when_high_water_mark_unchanged(mocker):
    """
    Given:
        - Previous run boundary: last_fetched_time = 200, last_fetched_ids = ["2a"] (caught up).
        - This run (also caught up) returns a NEW event "2b" ALSO at time 200,
          plus the already-seen "2a" (inclusive 'oldest' boundary).
    When:
        - Fetching, deduping "2a", and sending "2b".
    Then:
        - The high-water mark stays at 200 and last_fetched_ids == {"2a", "2b"} (merged with the
          previous ids, not replaced), so a subsequent run will dedup BOTH and never re-send them.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=200)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "2b", "date_create": 200},  # new, same timestamp as previous boundary
                {"id": "2a", "date_create": 200},  # already fetched
            ]
        ),
    )
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10},
        last_run={"last_fetched_time": 200, "last_fetched_ids": ["2a"]},
    )

    assert [e["id"] for e in events] == ["2b"]  # only the new one is sent
    assert last_run.get("last_fetched_time") == 200
    assert sorted(last_run.get("last_fetched_ids")) == ["2a", "2b"]  # merged, not replaced


def test_fetch_events_walks_multiple_windows_in_one_run_until_now(mocker):
    """
    Given:
        - last_fetched_time = 0, DEFAULT_MAX_FETCH_WINDOW = 100, now = 300.
        - Draining the backlog requires 3 windows: [0,100], [100,200], [200,300].
        - Each window returns one event; the limit is high so we never stop early.
    When:
        - Running a single fetch.
    Then:
        - All three windows are queried in ONE run (3 API calls) so a sparse backlog is
          drained quickly instead of one window per fetch interval.
        - All three events are returned oldest-first. The final window is caught up to 'now',
          so the mark is set to the newest sent event (250) and its id is kept for boundary
          dedup (we never advance the mark past 'now' without having seen an event there).
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=300)
    mocker.patch("SlackEventCollector.Config.DEFAULT_MAX_FETCH_WINDOW", 100)
    win1 = make_page([{"id": "a", "date_create": 50}])
    win2 = make_page([{"id": "b", "date_create": 150}])
    win3 = make_page([{"id": "c", "date_create": 250}])
    http = mocker.patch.object(Client, "_http_request", side_effect=[win1, win2, win3])
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 100},
        last_run={"last_fetched_time": 0, "last_fetched_ids": []},
    )

    assert http.call_count == 3  # one call per window, all in a single run
    assert [e["id"] for e in events] == ["a", "b", "c"]
    assert last_run.get("last_fetched_time") == 250  # newest sent event at the caught-up boundary
    assert last_run.get("last_fetched_ids") == ["c"]


def test_fetch_events_stops_walking_windows_when_limit_reached(mocker):
    """
    Given:
        - last_fetched_time = 0, DEFAULT_MAX_FETCH_WINDOW = 100, now = 300, limit = 2.
        - Window [0,100] returns two events (a@40, b@60), already filling the limit.
    When:
        - Running a single fetch.
    Then:
        - Only the first window is queried (1 API call) - the walk stops at the limit and does
          NOT keep walking to 'now'.
        - The oldest 2 events are returned and the resume point is the last sent event (b@60).
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=300)
    mocker.patch("SlackEventCollector.Config.DEFAULT_MAX_FETCH_WINDOW", 100)
    win1 = make_page(
        [
            {"id": "b", "date_create": 60},
            {"id": "a", "date_create": 40},
        ]
    )
    http = mocker.patch.object(Client, "_http_request", side_effect=[win1])
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 2},
        last_run={"last_fetched_time": 0, "last_fetched_ids": []},
    )

    assert http.call_count == 1  # stopped after the first window
    assert [e["id"] for e in events] == ["a", "b"]
    assert last_run.get("last_fetched_time") == 60
    assert last_run.get("last_fetched_ids") == ["b"]


def test_fetch_events_keeps_earlier_pages_when_a_later_page_fails(mocker):
    """
    Given:
        - Page 1 succeeds with events; page 2 raises an exception.
    When:
        - Paginating within the window.
    Then:
        - The page-1 events are still returned (oldest-first) instead of the whole run aborting,
          and the mark advances so the next run resumes from the high-water mark.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    # Later-page failures are logged at error level; mock it so the captured-output guard passes.
    mocker.patch.object(demisto, "error")
    page1 = make_page(
        [
            {"id": "2", "date_create": 200},
            {"id": "1", "date_create": 100},
        ],
        next_cursor="page2",
    )
    http = mocker.patch.object(
        Client,
        "_http_request",
        side_effect=[page1, DemistoException("boom on page 2")],
    )
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 100, "oldest": "100"},
        last_run={},
    )

    assert http.call_count == 2  # tried the second page and it failed
    assert [e["id"] for e in events] == ["1", "2"]  # page-1 events retained
    assert last_run.get("last_fetched_time") == 200


def test_fetch_events_first_page_failure_propagates(mocker):
    """
    Given:
        - The very first API call raises an exception (no events collected yet).
    When:
        - Running a fetch.
    Then:
        - The exception propagates out of fetch_events_command so the caller does NOT advance
          last_run (data-loss protection - the same window is retried next run).
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(Client, "_http_request", side_effect=DemistoException("boom on page 1"))

    with pytest.raises(DemistoException):
        fetch_events_command(
            Client(base_url=""),
            params={"limit": 100, "oldest": "100"},
            last_run={},
        )


def test_fetch_events_handles_none_last_run(mocker):
    """
    Given:
        - The fetch-events path is invoked with last_run=None (as some platforms return from
          demisto.getLastRun() on the very first run).
    When:
        - Running a fetch.
    Then:
        - It does NOT crash (None is treated as an empty last run) and returns events normally.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page([{"id": "1", "date_create": 500}]),
    )
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10, "oldest": "100"},
        last_run=None,
    )

    assert [e["id"] for e in events] == ["1"]
    assert last_run.get("last_fetched_time") == 500


def test_fetch_events_caps_number_of_windows_per_run(mocker):
    """
    Given:
        - A huge sparse backlog: last_fetched_time = 0, DEFAULT_MAX_FETCH_WINDOW = 10, now = 10000.
        - Draining fully would require 1000 windows; EACH window returns one new event (so the
          walk is never stopped early by an empty response) and the limit is high enough that a
          full batch is never reached either.
        - MAX_WINDOWS_PER_RUN caps how many windows a single run may walk.
    When:
        - Running a fetch.
    Then:
        - The run walks at most MAX_WINDOWS_PER_RUN windows (bounding run duration / API calls),
          advances the mark by that many windows, and resumes the rest on the next run - the very
          timeout / OOM protection the windowing is meant to provide.
    """
    from SlackEventCollector import Config, fetch_events_command

    MAX_WINDOWS_PER_RUN = Config.MAX_WINDOWS_PER_RUN
    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=10000)
    mocker.patch("SlackEventCollector.Config.DEFAULT_MAX_FETCH_WINDOW", 10)
    # Window n (1-indexed) covers [10*(n-1), 10*n]; return one event mid-window so it is neither
    # deduped nor empty, forcing the per-run window cap (not stop-on-empty) to be the limiter.
    pages = [make_page([{"id": str(n), "date_create": 10 * n - 5}]) for n in range(1, MAX_WINDOWS_PER_RUN + 1)]
    http = mocker.patch.object(Client, "_http_request", side_effect=pages)
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10000},  # high enough that 'have_enough' never triggers
        last_run={"last_fetched_time": 0, "last_fetched_ids": []},
    )

    assert len(events) == MAX_WINDOWS_PER_RUN  # one event collected per walked window
    assert http.call_count == MAX_WINDOWS_PER_RUN  # never walks more than the cap in one run
    # Advanced exactly MAX_WINDOWS_PER_RUN windows of 10 seconds each, then stops (still < now).
    assert last_run.get("last_fetched_time") == MAX_WINDOWS_PER_RUN * 10


""" Unit tests for pure helpers """


def test_filter_already_fetched_returns_unchanged_when_no_boundary_ids():
    """
    Given:
        - last_run has a last_fetched_time but an EMPTY last_fetched_ids.
    When:
        - Filtering already-fetched events.
    Then:
        - The list is returned unchanged (with no boundary ids there is nothing to dedup).
    """
    from SlackEventCollector import filter_already_fetched

    events = [{"id": "1", "date_create": 100}, {"id": "2", "date_create": 200}]
    result = filter_already_fetched(events, last_run={"last_fetched_time": 100, "last_fetched_ids": []})

    assert result == events


def test_filter_already_fetched_returns_unchanged_when_boundary_matches_nothing():
    """
    Given:
        - last_run boundary ids that do not match any event at the boundary_time.
    When:
        - Filtering already-fetched events.
    Then:
        - Nothing is dropped (no event both sits at boundary_time AND has a boundary id).
    """
    from SlackEventCollector import filter_already_fetched

    events = [{"id": "1", "date_create": 100}, {"id": "2", "date_create": 200}]
    result = filter_already_fetched(events, last_run={"last_fetched_time": 999, "last_fetched_ids": ["nope"]})

    assert result == events


def test_filter_already_fetched_drops_only_boundary_matches():
    """
    Given:
        - An event sitting exactly at boundary_time with a matching boundary id.
    When:
        - Filtering already-fetched events.
    Then:
        - Only that boundary event is dropped; all others are kept.
    """
    from SlackEventCollector import filter_already_fetched

    events = [
        {"id": "old", "date_create": 100},  # already-sent boundary event
        {"id": "new", "date_create": 100},  # same second, not yet sent
        {"id": "later", "date_create": 200},
    ]
    result = filter_already_fetched(events, last_run={"last_fetched_time": 100, "last_fetched_ids": ["old"]})

    assert [e["id"] for e in result] == ["new", "later"]


def test_sort_events_oldest_first_handles_missing_date_create_and_id():
    """
    Given:
        - Events some of which are missing 'date_create' and/or 'id'.
    When:
        - Sorting oldest-first.
    Then:
        - It does not raise; missing date_create sorts as 0 (oldest) and missing id as "",
          giving a deterministic ascending order by (date_create, id).
    """
    from SlackEventCollector import sort_events_oldest_first

    events = [
        {"id": "b", "date_create": 200},
        {"date_create": 200},  # missing id -> "" sorts before "b" at the same time
        {"id": "a", "date_create": 100},
        {"id": "z"},  # missing date_create -> 0 (oldest)
    ]
    result = sort_events_oldest_first(events)

    # date_create: z=0, a=100, then the two at 200 ordered by id ("" before "b")
    assert result == [
        {"id": "z"},
        {"id": "a", "date_create": 100},
        {"date_create": 200},
        {"id": "b", "date_create": 200},
    ]


def test_update_last_run_last_fetched_time_never_regresses_across_windows():
    """
    Given:
        - A multi-window walk where an earlier window advanced the mark, and a later
          update carries an OLDER newest-sent timestamp (limit truncation landing inside an
          earlier window).
    When:
        - update_last_run is applied window after window.
    Then:
        - last_fetched_time never regresses below the previously stored value - the exact
          XSUP-74148 duplicate-ingestion guard. (The drained-below-now branch only advances when
          window_end is strictly greater than the stored mark.)
    """
    from SlackEventCollector import update_last_run

    last_run: dict = {"last_fetched_time": 0, "last_fetched_ids": []}

    # Window 1 drained below 'now' -> mark advances to window_end=100.
    update_last_run(last_run, [{"id": "a", "date_create": 50}], window_end=100, reached_limit=False, caught_up=False)
    assert last_run["last_fetched_time"] == 100

    # A stale/earlier drained window (window_end=80) must NOT pull the mark backwards.
    update_last_run(last_run, [{"id": "b", "date_create": 70}], window_end=80, reached_limit=False, caught_up=False)
    assert last_run["last_fetched_time"] == 100  # unchanged, no regression


def test_fetch_events_adds_time_field_to_every_event(mocker):
    """
    Given:
        - A fetch that returns several events.
    When:
        - Running fetch_events_command.
    Then:
        - Every returned event carries a '_time' field derived from its 'date_create'.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "3", "date_create": 300},
                {"id": "2", "date_create": 200},
                {"id": "1", "date_create": 100},
            ]
        ),
    )
    events, _ = fetch_events_command(
        Client(base_url=""),
        params={"limit": 10, "oldest": "100"},
        last_run={},
    )

    assert events, "expected events to be returned"
    assert all("_time" in e for e in events)


def test_fetch_events_rate_limited_later_page_retains_events_and_logs_error(mocker):
    """
    Given:
        - Page 1 succeeds; page 2 fails with a DemistoException carrying a 429 (rate limit) after
          retries are exhausted.
    When:
        - Paginating within the window.
    Then:
        - The events already collected from page 1 are retained (not lost), and the partial-
          collection failure is logged at ERROR level so operators can see it.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    error_log = mocker.patch.object(demisto, "error")
    page1 = make_page([{"id": "1", "date_create": 100}], next_cursor="page2")
    mocker.patch.object(
        Client,
        "_http_request",
        side_effect=[page1, DemistoException("Error in API call [429] - Too Many Requests")],
    )
    events, last_run = fetch_events_command(
        Client(base_url=""),
        params={"limit": 100, "oldest": "100"},
        last_run={},
    )

    assert [e["id"] for e in events] == ["1"]  # page-1 events retained despite the 429
    assert last_run.get("last_fetched_time") == 100
    assert error_log.called  # partial-collection failure surfaced at error level


def test_fetch_events_limit_zero_defaults_to_1000(mocker):
    """
    Given:
        - limit = 0 is passed.
    When:
        - Running a fetch.
    Then:
        - CURRENT behavior: `arg_to_number(...) or 1000` treats 0 as falsy and defaults to 1000,
          so all available events (well under 1000) are returned.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page([{"id": "2", "date_create": 200}, {"id": "1", "date_create": 100}]),
    )
    events, _ = fetch_events_command(
        Client(base_url=""),
        params={"limit": 0, "oldest": "100"},
        last_run={},
    )

    assert [e["id"] for e in events] == ["1", "2"]  # 0 -> default 1000, everything returned


def test_fetch_events_negative_limit_current_behavior(mocker):
    """
    Given:
        - A negative limit (-1) is passed.
    When:
        - Running a fetch.
    Then:
        - CURRENT behavior: a negative limit is truthy, so it is used as-is in the final slice
          `collected[:limit]`, which for -1 drops the last (newest) event.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "3", "date_create": 300},
                {"id": "2", "date_create": 200},
                {"id": "1", "date_create": 100},
            ]
        ),
    )
    events, _ = fetch_events_command(
        Client(base_url=""),
        params={"limit": -1, "oldest": "100"},
        last_run={},
    )

    # oldest-first sorted [1, 2, 3], sliced [:-1] -> [1, 2]
    assert [e["id"] for e in events] == ["1", "2"]


def test_get_events_uses_oldest_arg_as_window_start(mocker):
    """
    Given:
        - An explicit 'oldest' argument passed to the manual slack-get-events command.
    When:
        - Running the command.
    Then:
        - The first API call's `oldest` equals the 'oldest' argument (100), so the command is
          driven purely by its arguments.
    """
    from SlackEventCollector import get_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=1000)
    mocker.patch.object(demisto, "getLastRun", return_value={"last_fetched_time": 500, "last_fetched_ids": []})
    http = mocker.patch.object(Client, "_http_request", return_value=make_page([{"id": "9", "date_create": 600}]))

    get_events_command(Client(base_url=""), args={"limit": 10, "oldest": "100"})

    first_call_params = http.call_args_list[0].kwargs["params"]
    assert first_call_params["oldest"] == 100  # driven by the 'oldest' argument


def test_get_events_uses_latest_arg_as_upper_bound(mocker):
    """
    Given:
        - slack-get-events called with 'oldest' and 'latest' arguments and a DEFAULT_MAX_FETCH_WINDOW
          large enough for one window to span the whole [oldest, latest] range.
    When:
        - Running the command.
    Then:
        - The API call's `latest` equals the 'latest' argument (300), and the events within
          [oldest, latest] are returned oldest-first.
    """
    from SlackEventCollector import get_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=100000)
    mocker.patch.object(demisto, "getLastRun", return_value={})
    http = mocker.patch.object(
        Client,
        "_http_request",
        return_value=make_page(
            [
                {"id": "3", "date_create": 300},
                {"id": "2", "date_create": 200},
                {"id": "1", "date_create": 100},
            ]
        ),
    )

    events, _ = get_events_command(Client(base_url=""), args={"limit": 10, "oldest": "100", "latest": "300"})

    first_call_params = http.call_args_list[0].kwargs["params"]
    assert first_call_params["oldest"] == 100
    assert first_call_params["latest"] == 300  # bounded by the 'latest' argument
    assert [e["id"] for e in events] == ["1", "2", "3"]  # oldest-first


def test_get_events_walks_multiple_windows_until_latest(mocker):
    """
    Given:
        - slack-get-events with oldest=100, latest=400, DEFAULT_MAX_FETCH_WINDOW=100 and a high limit.
        - The range [100, 400] spans three forward windows, each returning one event.
    When:
        - Running the command.
    Then:
        - It walks the windows forward (the same mechanism as fetch-events), querying all three
          windows in a single run and returning every event oldest-first.
    """
    from SlackEventCollector import get_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=100000)
    mocker.patch("SlackEventCollector.Config.DEFAULT_MAX_FETCH_WINDOW", 100)
    mocker.patch.object(demisto, "getLastRun", return_value={})
    win1 = make_page([{"id": "a", "date_create": 150}])
    win2 = make_page([{"id": "b", "date_create": 250}])
    win3 = make_page([{"id": "c", "date_create": 350}])
    http = mocker.patch.object(Client, "_http_request", side_effect=[win1, win2, win3])

    events, _ = get_events_command(Client(base_url=""), args={"limit": 100, "oldest": "100", "latest": "400"})

    assert http.call_count == 3  # walked all three windows in one run
    # the final window's upper bound is the 'latest' argument
    assert http.call_args_list[-1].kwargs["params"]["latest"] == 400
    assert [e["id"] for e in events] == ["a", "b", "c"]  # oldest-first


def test_get_events_stops_walking_windows_when_limit_reached(mocker):
    """
    Given:
        - slack-get-events with a 'latest' upper bound spanning several windows and a small limit.
        - The first window returns enough events to fill the limit.
    When:
        - Running the command.
    Then:
        - The walk stops as soon as the limit is filled (a single API call), returning the oldest
          'limit' events - the same limit-driven loop as fetch-events.
    """
    from SlackEventCollector import get_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=100000)
    mocker.patch("SlackEventCollector.Config.DEFAULT_MAX_FETCH_WINDOW", 100)
    mocker.patch.object(demisto, "getLastRun", return_value={})
    win1 = make_page(
        [
            {"id": "b", "date_create": 60},
            {"id": "a", "date_create": 40},
        ]
    )
    http = mocker.patch.object(Client, "_http_request", side_effect=[win1])

    events, _ = get_events_command(Client(base_url=""), args={"limit": 2, "oldest": "0", "latest": "300"})

    assert http.call_count == 1  # stopped once the limit was filled
    assert [e["id"] for e in events] == ["a", "b"]  # oldest-first, limited


def test_fetch_events_later_window_first_page_failure_propagates(mocker):
    """
    Given:
        - Window 1 returns events; window 2's FIRST page raises a DemistoException.
    When:
        - Walking multiple windows in one run.
    Then:
        - CURRENT behavior: get_all_logs_in_window re-raises when the first page of a window fails
          (its per-window buffer is empty), so the exception propagates out of the run. (Documents
          that earlier windows' events are NOT independently flushed on a later-window first-page
          failure.)
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=300)
    mocker.patch("SlackEventCollector.Config.DEFAULT_MAX_FETCH_WINDOW", 100)
    win1 = make_page([{"id": "a", "date_create": 50}])
    mocker.patch.object(
        Client,
        "_http_request",
        side_effect=[win1, DemistoException("boom on window 2 page 1")],
    )

    with pytest.raises(DemistoException):
        fetch_events_command(
            Client(base_url=""),
            params={"limit": 100},
            last_run={"last_fetched_time": 0, "last_fetched_ids": []},
        )


def test_get_events_inverted_range_returns_error(mocker):
    """
    Given:
        - slack-get-events called with 'oldest' later than 'latest' (an inverted range).
    When:
        - Running the command.
    Then:
        - return_error is raised (SystemExit) with a clear message, and no API call is made.
    """
    from SlackEventCollector import get_events_command

    return_error = mocker.patch("SlackEventCollector.return_error", side_effect=SystemExit)
    http = mocker.patch.object(Client, "_http_request")

    with pytest.raises(SystemExit):
        get_events_command(Client(base_url=""), args={"limit": 10, "oldest": "300", "latest": "100"})

    assert "'oldest' argument must be earlier than or equal to the 'latest'" in return_error.call_args.args[0]
    http.assert_not_called()  # validation short-circuits before any API call


def test_fetch_events_no_oldest_arg_skips_range_validation(mocker):
    """
    Given:
        - The automated fetch-events collector, which never supplies an 'oldest' argument.
    When:
        - Running the collection cycle (upper_bound defaults to the current time).
    Then:
        - The inverted-range guard is a no-op (no return_error) and collection proceeds normally.
    """
    from SlackEventCollector import fetch_events_command

    mocker.patch("SlackEventCollector.get_now_timestamp", return_value=300)
    return_error = mocker.patch("SlackEventCollector.return_error", side_effect=SystemExit)
    mocker.patch.object(Client, "_http_request", return_value=make_page([{"id": "a", "date_create": 100}]))

    events, _ = fetch_events_command(
        Client(base_url=""),
        params={"limit": 100},
        last_run={"last_fetched_time": 0, "last_fetched_ids": []},
    )

    return_error.assert_not_called()  # no 'oldest' arg -> guard is a no-op
    assert [e["id"] for e in events] == ["a"]