import json from datetime import UTC, datetime, timedelta import pytest from MenloSecurity import ( Client, DATE_FORMAT, MAX_EVENTS_PER_PAGE, _drop_boundary_duplicates, _enrich_events, _normalize_page, fetch_events, fetch_events_command, get_boundary_hashes, get_events_command, get_events_for_log_type, hash_event, ) # ─── Helpers ───────────────────────────────────────────────────────────────── def load_test_data(filename: str) -> dict: """Load JSON test data from the test_data directory.""" with open(f"test_data/{filename}") as f: return json.load(f) @pytest.fixture def mock_client(mocker) -> Client: """Return a mocked Client instance with ContentClient.__init__ patched.""" mocker.patch("MenloSecurity.ContentClient.__init__", return_value=None) client = Client.__new__(Client) client._token = "test-token-12345" client._base_url = "https://logs.menlosecurity.com" # Default to v2 (Admin token) — matches DEFAULT_TOKEN_TYPE in production code. client._api_path = "/api/rep/v2/fetch/client_select" return client # All event/response examples live in test_data/. The integration enriches event dicts # in-place, so each helper returns a fresh deep copy to avoid cross-test pollution. _WEB_RESPONSE = load_test_data("web_logs_response.json") _EMAIL_RESPONSE = load_test_data("email_logs_response.json") _EMPTY_RESPONSE = load_test_data("empty_response.json") def make_web_response(event_time: str = "2024-01-15T10:00:40.548000") -> dict: """Return a fresh deep-copied web response with an optional event_time override.""" response = json.loads(json.dumps(_WEB_RESPONSE)) response["result"]["events"][0]["event"]["event_time"] = event_time return response def make_email_response() -> dict: """Return a fresh deep-copied email response.""" return json.loads(json.dumps(_EMAIL_RESPONSE)) def make_empty_response() -> dict: """Return a fresh deep-copied empty response.""" return json.loads(json.dumps(_EMPTY_RESPONSE)) def make_response(payload: dict | list, mocker): """Mock an httpx.Response for fetch_log_page tests (post() returns a Response object).""" body = json.dumps(payload).encode() return mocker.MagicMock(content=body, status_code=200, headers={}, **{"json.return_value": payload}) # ─── Client Tests ───────────────────────────────────────────────────────────── class TestClient: def test_fetch_log_page_first_page_no_paging_identifiers(self, mock_client: Client, mocker): """ Given: - A first-page request (no pagingIdentifiers). When: - Calling fetch_log_page without paging_identifiers. Then: - The underlying post() call does NOT include pagingIdentifiers in the body. - The correct log_type and token are in the body. """ web_response = load_test_data("web_logs_response.json") mock_post = mocker.patch.object(mock_client, "post", return_value=make_response(web_response, mocker)) result = mock_client.fetch_log_page(log_type="web", start=1700000000, end=1700003600, limit=1000) assert result == web_response call_kwargs = mock_post.call_args.kwargs body = call_kwargs["json_data"] assert "pagingIdentifiers" not in body assert body["log_type"] == "web" assert body["token"] == "test-token-12345" def test_fetch_log_page_with_paging_identifiers(self, mock_client: Client, mocker): """ Given: - A subsequent page request with pagingIdentifiers from the previous response. When: - Calling fetch_log_page with paging_identifiers. Then: - The pagingIdentifiers are included in the POST body. """ mock_post = mocker.patch.object(mock_client, "post", return_value=make_response(make_empty_response(), mocker)) paging = {"next_time": "2024-01-15T10:00:00.000Z", "hashes": {"abc123": 0}, "last_iteration": True} mock_client.fetch_log_page(log_type="web", start=1700000000, end=1700003600, limit=1000, paging_identifiers=paging) body = mock_post.call_args.kwargs["json_data"] assert body["pagingIdentifiers"] == paging def test_fetch_log_page_url_params(self, mock_client: Client, mocker): """ Given: - A log page request with specific start, end, and limit values. When: - Calling fetch_log_page. Then: - start, end, limit, and format=json are passed as URL query parameters. """ mock_post = mocker.patch.object(mock_client, "post", return_value=make_response(make_empty_response(), mocker)) mock_client.fetch_log_page(log_type="audit", start=1700000000, end=1700003600, limit=500) params = mock_post.call_args.kwargs["params"] assert params["start"] == 1700000000 assert params["end"] == 1700003600 assert params["limit"] == 500 assert params["format"] == "json" def test_fetch_log_page_returns_none_on_empty_body(self, mock_client: Client, mocker): """ Given: - The API returns an empty 200 response (Content-Length: 0). When: - Calling fetch_log_page. Then: - Returns None (not an error). """ mock_resp = mocker.MagicMock(content=b"", status_code=200, headers={}) mocker.patch.object(mock_client, "post", return_value=mock_resp) result = mock_client.fetch_log_page(log_type="web", start=1700000000, end=1700003600) assert result is None def test_fetch_log_page_raises_on_non_json_body(self, mock_client: Client, mocker): """ Given: - The API returns a non-JSON body (e.g. HTML auth error page). When: - Calling fetch_log_page. Then: - Raises ValueError with the response snippet. """ html = b"401:Unauthorized" mock_resp = mocker.MagicMock(content=html, status_code=401, headers={}) mock_resp.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) mock_resp.text = html.decode() mocker.patch.object(mock_client, "post", return_value=mock_resp) with pytest.raises(ValueError, match="Non-JSON response"): mock_client.fetch_log_page(log_type="web", start=1700000000, end=1700003600) # ─── get_events_for_log_type Tests ─────────────────────────────────────────── class TestGetEventsForLogType: def test_single_page_fetch_enriches_events(self, mock_client: Client, mocker): """ Given: - A single page of web logs followed by an empty response. When: - Calling get_events_for_log_type with enrich=True (default). Then: - Events are returned with _time and source_log_type fields added. - The event envelope {"event": {...}} is unwrapped. """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) events = get_events_for_log_type( client=mock_client, log_type_ui="web", start_epoch=1700000000, end_epoch=1700003600, max_events=5000 ) assert len(events) == 1 assert events[0]["source_log_type"] == "web_logs" assert "_time" in events[0] assert "2024-01-15" in events[0]["_time"] assert events[0]["domain"] == "example.com" def test_no_enrichment_when_enrich_false(self, mock_client: Client, mocker): """ Given: - A single page of web logs. When: - Calling get_events_for_log_type with enrich=False. Then: - Events are returned WITHOUT _time or source_log_type fields. """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) events = get_events_for_log_type( client=mock_client, log_type_ui="web", start_epoch=1700000000, end_epoch=1700003600, max_events=5000, enrich=False ) assert len(events) == 1 assert "_time" not in events[0] assert "source_log_type" not in events[0] def test_safemail_maps_to_email_api_type(self, mock_client: Client, mocker): """ Given: - The "safemail" UI log type is selected. When: - Calling get_events_for_log_type. Then: - fetch_log_page is called with log_type="email" (not "safemail"). - Events have source_log_type="email_logs". """ mock_fetch = mocker.patch.object( mock_client, "fetch_log_page", side_effect=[make_email_response(), make_empty_response()] ) events = get_events_for_log_type( client=mock_client, log_type_ui="safemail", start_epoch=1700000000, end_epoch=1700003600, max_events=5000 ) assert len(events) == 1 assert events[0]["source_log_type"] == "email_logs" # Verify the API was called with log_type="email" (not "safemail") assert mock_fetch.call_args_list[0].kwargs["log_type"] == "email" def test_single_call_uses_max_events_as_limit_when_below_page_size(self, mock_client: Client, mocker): """ Given: max_events=50 (less than MAX_EVENTS_PER_PAGE). When: Calling get_events_for_log_type. Then: One API call with limit=50 (no pagination needed). """ mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) get_events_for_log_type( client=mock_client, log_type_ui="web", start_epoch=1700000000, end_epoch=1700003600, max_events=50 ) assert mock_fetch.call_count == 1 assert mock_fetch.call_args.kwargs["limit"] == 50 def test_paginated_calls_keep_constant_page_size_and_trim_overshoot(self, mock_client: Client, mocker): """ Given: max_events exceeds MAX_EVENTS_PER_PAGE — requires pagination, and the API returns full pages. When: Calling get_events_for_log_type. Then: All calls use limit=MAX_EVENTS_PER_PAGE (page size stays constant), and the result is trimmed to exactly max_events. """ # Use a max_events that forces 2 paginated calls: one full page + a trim on the second. max_events = MAX_EVENTS_PER_PAGE + (MAX_EVENTS_PER_PAGE // 2) # e.g. 15000 with page=10000 # Build full pages by replicating a fresh deep-copy per event so in-place enrichment # by the integration doesn't pollute the shared template. events_page_1 = [make_web_response()["result"]["events"][0] for _ in range(MAX_EVENTS_PER_PAGE)] events_page_2 = [make_web_response()["result"]["events"][0] for _ in range(MAX_EVENTS_PER_PAGE)] full_page_with_cursor = { "result": { "events": events_page_1, "pagingIdentifiers": {"next_time": "2024-01-15T11:00:00.000Z"}, } } full_page_no_cursor = { "result": {"events": events_page_2, "pagingIdentifiers": {}}, } mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", side_effect=[full_page_with_cursor, full_page_no_cursor]) events = get_events_for_log_type( client=mock_client, log_type_ui="web", start_epoch=1700000000, end_epoch=1700003600, max_events=max_events ) assert mock_fetch.call_count == 2 assert mock_fetch.call_args_list[0].kwargs["limit"] == MAX_EVENTS_PER_PAGE assert mock_fetch.call_args_list[1].kwargs["limit"] == MAX_EVENTS_PER_PAGE assert len(events) == max_events def test_empty_response_stops_pagination(self, mock_client: Client, mocker): """ Given: - The API returns an empty events list on the first call. When: - Calling get_events_for_log_type. Then: - Pagination stops immediately and no events are returned. - Only one API call is made. """ mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) events = get_events_for_log_type( client=mock_client, log_type_ui="audit", start_epoch=1700000000, end_epoch=1700003600, max_events=5000 ) assert events == [] assert mock_fetch.call_count == 1 def test_entire_first_page_is_duplicates_emits_nothing(self, mock_client: Client, mocker): """ Given: - The first (and only) page consists entirely of events that duplicate the previous cycle's boundary, and there is no next-page cursor. When: - Calling get_events_for_log_type with the matching boundary_hashes + last_fetch_time. Then: - Every event is dropped by _drop_boundary_duplicates, the "whole page was duplicates" break path is taken, and no events are returned (none emitted). """ boundary_time = "2024-01-15T10:00:00" dup_events = [{"event": {"event_time": boundary_time, "id": "a"}}, {"event": {"event_time": boundary_time, "id": "b"}}] # Boundary hashes are computed from enriched events, so enrich the same way to match. boundary_hashes = {hash_event(e) for e in _enrich_events(dup_events, "audit", enrich=True)} page = {"result": {"events": dup_events, "pagingIdentifiers": {}}} # no cursor ⇒ single page mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", return_value=page) events = get_events_for_log_type( client=mock_client, log_type_ui="audit", start_epoch=1700000000, end_epoch=1700003600, max_events=5000, boundary_hashes=boundary_hashes, last_fetch_time=boundary_time, ) assert events == [] # all dropped as duplicates assert mock_fetch.call_count == 1 def test_list_of_wrappers_response_flattens_events(self, mock_client: Client, mocker): """ Given: - The API returns a list of response wrappers (observed live behavior). When: - Calling get_events_for_log_type. Then: - Events from all wrappers are flattened into a single list. """ wrapper1 = { "timestamp": "2024-01-15T10:00:00.000Z", "result": { "events": [{"event": {"event_time": "2024-01-15T10:00:00", "domain": "a.com"}}], "pagingIdentifiers": {}, }, } wrapper2 = { "timestamp": "2024-01-15T10:00:01.000Z", "result": { "events": [{"event": {"event_time": "2024-01-15T10:00:01", "domain": "b.com"}}], "pagingIdentifiers": {}, }, } # Return a list of 2 wrappers, then empty to stop pagination. mocker.patch.object(mock_client, "fetch_log_page", side_effect=[[wrapper1, wrapper2], None]) events = get_events_for_log_type(mock_client, "web", 1700000000, 1700003600, max_events=100) assert len(events) == 2 assert events[0]["domain"] == "a.com" assert events[1]["domain"] == "b.com" def test_api_error_propagates(self, mock_client: Client, mocker): """ Given: - The first API call succeeds but the second raises an error. When: - Calling get_events_for_log_type. Then: - The error propagates (not silently swallowed). """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), Exception("Connection error")]) with pytest.raises(Exception, match="Connection error"): get_events_for_log_type( client=mock_client, log_type_ui="web", start_epoch=1700000000, end_epoch=1700003600, max_events=5000 ) # ─── fetch_events Tests ─────────────────────────────────────────────────────── class TestFetchEvents: def test_first_fetch_uses_first_fetch_time(self, mock_client: Client, mocker): """ Given: - No last_run (first fetch cycle). When: - Calling fetch_events with first_fetch_time="1 day". Then: - Events are fetched and next_run is populated with last_fetch_time. """ mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) next_run, events = fetch_events( client=mock_client, last_run={}, log_types=["web"], first_fetch_time="1 day", max_events_per_fetch_per_type=5000 ) assert events == [] assert "web" in next_run assert "last_fetch_time" in next_run["web"] def test_subsequent_fetch_uses_last_run_time(self, mock_client: Client, mocker): """ Given: - A last_run with last_fetch_time="2024-01-15T09:00:00Z". When: - Calling fetch_events. Then: - fetch_log_page is called with start equal to the epoch of 2024-01-15T09:00:00Z. """ from MenloSecurity import timestamp_to_epoch last_fetch_time = "2024-01-15T09:00:00Z" expected_start = timestamp_to_epoch(last_fetch_time) mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) fetch_events( client=mock_client, last_run={"web": {"last_fetch_time": last_fetch_time}}, log_types=["web"], first_fetch_time="3 days", max_events_per_fetch_per_type=5000, ) assert mock_fetch.call_args.kwargs["start"] == expected_start def test_all_selected_log_types_are_fetched(self, mock_client: Client, mocker): """ Given: - Three log types are selected: web, audit, dlp. When: - Calling fetch_events. Then: - fetch_log_page is called at least once per log type. - next_run contains an entry for each log type. """ mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) next_run, _ = fetch_events( client=mock_client, last_run={}, log_types=["web", "audit", "dlp"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000, ) assert "web" in next_run assert "audit" in next_run assert "dlp" in next_run def test_next_run_advances_to_now_on_first_fetch_with_no_events(self, mock_client: Client, mocker): """ Given: - No last_run (first fetch cycle) and no events returned. When: - Calling fetch_events. Then: - next_run["web"]["last_fetch_time"] is set to the request's end time (advances the window). - boundary_hashes is empty. """ from CommonServerPython import arg_to_datetime mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) next_run, _ = fetch_events( client=mock_client, last_run={}, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000 ) assert "last_fetch_time" in next_run["web"] assert arg_to_datetime(next_run["web"]["last_fetch_time"]) is not None assert next_run["web"]["boundary_hashes"] == [] def test_next_run_advances_past_empty_capped_window(self, mock_client: Client, mocker): """ Given: - A last_run whose last_fetch_time is far in the past (so the query window is capped to MAX_FETCH_WINDOW_SECONDS below `now` — i.e. we're behind). - No events are returned in this (empty) capped window. When: - Calling fetch_events. Then: - next_run["web"] does NOT preserve the old start (that would deadlock, re-querying the same empty window forever). Instead last_fetch_time advances forward by exactly one window (start + MAX_FETCH_WINDOW_SECONDS), and boundary_hashes is cleared. """ mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) prev_state = {"last_fetch_time": "2024-01-15T09:00:00Z", "boundary_hashes": ["abc123hash"]} last_run = {"web": prev_state} next_run, events = fetch_events( client=mock_client, last_run=last_run, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000, ) assert events == [] # State must have moved forward (not preserved) to escape the empty window. assert next_run["web"] != prev_state assert next_run["web"]["boundary_hashes"] == [] # New start = old start + one window (2024-01-15T09:00:00Z + 300s = 09:05:00Z). assert next_run["web"]["last_fetch_time"] == "2024-01-15T09:05:00Z" # Still behind ⇒ loop immediately. assert next_run.get("nextTrigger") == "0" def test_cap_saturation_sets_next_trigger(self, mock_client: Client, mocker): """ Given: - A log type returns a full page (events_emitted >= max_events_per_fetch_per_type) with a next-page cursor, so it hits the per-type cap (saturated, distinct from window-capped). When: - Calling fetch_events. Then: - nextTrigger=0 is set via the is_saturated branch so the engine immediately re-dispatches to drain the rest of the backlog. """ max_events = 3 saturated_page = { "result": { "events": [{"event": {"event_time": f"2024-01-15T10:00:0{i}.000"}} for i in range(max_events)], "pagingIdentifiers": {"next_time": "more"}, # cursor present ⇒ more pages remain } } mocker.patch.object(mock_client, "fetch_log_page", return_value=saturated_page) next_run, _ = fetch_events( client=mock_client, last_run={}, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=max_events, ) assert next_run.get("nextTrigger") == "0" def test_next_run_preserves_state_when_caught_up_and_no_events(self, mock_client: Client, mocker): """ Given: - A last_run whose last_fetch_time is within MAX_FETCH_WINDOW_SECONDS of `now` (so the window reaches `now` and is NOT capped — i.e. we're caught up). - No events are returned. When: - Calling fetch_events. Then: - next_run["web"] preserves the previous state (re-poll same boundary next cycle), and no nextTrigger is set (we're caught up → the loop should sleep). """ mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) # last_fetch_time ~1 minute ago ⇒ window [start, now] is < 5 min ⇒ not capped. recent = (datetime.now(UTC) - timedelta(minutes=1)).strftime(DATE_FORMAT) prev_state = {"last_fetch_time": recent, "boundary_hashes": ["abc123hash"]} last_run = {"web": prev_state} next_run, events = fetch_events( client=mock_client, last_run=last_run, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000, ) assert events == [] assert next_run["web"] == prev_state assert next_run.get("nextTrigger") is None def test_next_run_uses_last_event_time_when_events_exist(self, mock_client: Client, mocker): """ Given: - One event is returned with event_time "2024-01-15T10:00:40.548000". When: - Calling fetch_events. Then: - next_run["web"]["last_fetch_time"] equals the event's event_time. - next_run["web"]["boundary_hashes"] contains one hash. """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) next_run, events = fetch_events( client=mock_client, last_run={}, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000 ) assert len(events) == 1 assert "2024-01-15" in next_run["web"]["last_fetch_time"] assert "boundary_hashes" in next_run["web"] assert len(next_run["web"]["boundary_hashes"]) == 1 def test_dedup_removes_events_matching_boundary_hash(self, mock_client: Client, mocker): """ Given: - A previous cycle ended with an event whose hash is stored in boundary_hashes. When: - The same event is returned at the start of the next cycle. Then: - The duplicate event is filtered out. """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) # First cycle: get the event and its hash _, events_cycle1 = fetch_events( client=mock_client, last_run={}, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000 ) assert len(events_cycle1) == 1 boundary_hash = hash_event(events_cycle1[0]) # Second cycle: same event returned, should be deduped mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) last_run = {"web": {"last_fetch_time": "2024-01-15T10:00:40.548000", "boundary_hashes": [boundary_hash]}} _, events_cycle2 = fetch_events( client=mock_client, last_run=last_run, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000, ) assert len(events_cycle2) == 0 def test_dedup_keeps_events_with_different_hash(self, mock_client: Client, mocker): """ Given: - An event has the same event_time as last_fetch_time but a different hash. When: - Calling fetch_events. Then: - The event is NOT filtered out (different content = not a duplicate). """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) last_run = {"web": {"last_fetch_time": "2024-01-15T10:00:40.548000", "boundary_hashes": ["deadbeef00000000"]}} _, events = fetch_events( client=mock_client, last_run=last_run, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000, ) assert len(events) == 1 def test_no_dedup_on_first_fetch(self, mock_client: Client, mocker): """ Given: - No last_run (first fetch cycle, no boundary_hashes). When: - Calling fetch_events. Then: - All events are returned without any dedup filtering. """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) _, events = fetch_events( client=mock_client, last_run={}, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000 ) assert len(events) == 1 def test_failed_log_type_preserves_previous_state(self, mock_client: Client, mocker): """ Given: - "web" log type raises an exception during fetch. - "audit" log type succeeds. When: - Calling fetch_events. Then: - "audit" events are returned. - "web" state is preserved from last_run (not overwritten). """ mocker.patch("MenloSecurity.demisto.error") # suppress stdout output that conftest treats as failure def side_effect_by_log_type(log_type: str, **kwargs): if log_type == "web": raise Exception("API error for web") return make_empty_response() mocker.patch.object(mock_client, "fetch_log_page", side_effect=side_effect_by_log_type) prev_web_state = {"last_fetch_time": "2024-01-15T09:00:00Z", "boundary_hashes": []} last_run = {"web": prev_web_state} next_run, events = fetch_events( client=mock_client, last_run=last_run, log_types=["web", "audit"], first_fetch_time="1 hour", max_events_per_fetch_per_type=5000, ) # web state preserved from last_run assert next_run["web"] == prev_web_state # audit state updated assert "audit" in next_run # ─── Hash / Dedup Helper Tests ──────────────────────────────────────────────── def test_end_to_end_multi_type_with_dedup_and_state(self, mock_client: Client, mocker): """ Given: - Two log types (web, audit) configured. - web has a previous last_run with a boundary hash matching the first returned event. - audit is a first fetch with events. When: - Calling fetch_events. Then: - web: the duplicate event is removed, remaining events are returned. - audit: all events are returned (no dedup on first fetch). - next_run has updated state for both types. """ dup_domain = "duplicate-event.test" new_domain = "new-event.test" web_event_dup = {"event": {"event_time": "2024-01-15T10:00:00", "domain": dup_domain}} web_event_new = {"event": {"event_time": "2024-01-15T10:00:01", "domain": new_domain}} audit_event = {"event": {"event_time": "2024-01-15T10:00:00", "name": "login"}} web_response = { "result": { "events": [web_event_dup, web_event_new], "pagingIdentifiers": {}, } } audit_response = { "result": { "events": [audit_event], "pagingIdentifiers": {}, } } # The hash must match the ENRICHED event (after _time and source_log_type are added). enriched_dup = { **web_event_dup["event"], "_time": "2024-01-15T10:00:00Z", "source_log_type": "web_logs", } dup_hash = hash_event(enriched_dup) # Each type calls fetch_log_page twice: once for data, once returns None to stop pagination. call_results: dict[str, list] = { "web": [web_response, None], "audit": [audit_response, None], } def mock_fetch(log_type, **kwargs): return call_results[log_type].pop(0) if call_results.get(log_type) else None mocker.patch.object(mock_client, "fetch_log_page", side_effect=mock_fetch) last_run = { "web": { "last_fetch_time": "2024-01-15T10:00:00", "boundary_hashes": [dup_hash], } } next_run, events = fetch_events( client=mock_client, last_run=last_run, log_types=["web", "audit"], first_fetch_time="3 hours", max_events_per_fetch_per_type=5000, ) # web: 1 dup removed, 1 new event kept. audit: 1 event (no dedup on first fetch). assert len(events) == 2 event_domains = [e.get("domain") for e in events if "domain" in e] assert new_domain in event_domains assert dup_domain not in event_domains # Both types have updated state in next_run. assert "web" in next_run assert "audit" in next_run assert next_run["web"]["last_fetch_time"] == "2024-01-15T10:00:01" assert next_run["audit"]["last_fetch_time"] == "2024-01-15T10:00:00" class TestHashHelpers: def test_hash_event_is_deterministic(self): """ Given: - The same event dict. When: - Calling hash_event twice. Then: - Both calls return the same hash. """ event = {"event_time": "2024-01-15T10:00:00", "domain": "example.com", "userid": "user@test.com"} assert hash_event(event) == hash_event(event) def test_hash_event_differs_for_different_events(self): """ Given: - Two events with the same timestamp but different domain. When: - Calling hash_event on each. Then: - The hashes are different. """ event1 = {"event_time": "2024-01-15T10:00:00", "domain": "example.com"} event2 = {"event_time": "2024-01-15T10:00:00", "domain": "other.com"} assert hash_event(event1) != hash_event(event2) def test_get_boundary_hashes_returns_hashes_of_last_events(self): """ Given: - Three events: one at T0, two at T1 (boundary). When: - Calling get_boundary_hashes with boundary_time=T1. Then: - Two hashes are returned (for the two events at T1). """ events = [ {"event_time": "2024-01-15T10:00:00", "domain": "a.com"}, {"event_time": "2024-01-15T10:00:01", "domain": "b.com"}, {"event_time": "2024-01-15T10:00:01", "domain": "c.com"}, ] hashes = get_boundary_hashes(events, "2024-01-15T10:00:01") assert len(hashes) == 2 assert hash_event(events[1]) in hashes assert hash_event(events[2]) in hashes assert hash_event(events[0]) not in hashes def test_get_boundary_hashes_stops_at_different_timestamp(self): """ Given: - Two events at different timestamps. When: - Calling get_boundary_hashes with the later timestamp. Then: - Only the event at the boundary timestamp is hashed. """ events = [ {"event_time": "2024-01-15T10:00:00", "domain": "a.com"}, {"event_time": "2024-01-15T10:00:01", "domain": "b.com"}, ] hashes = get_boundary_hashes(events, "2024-01-15T10:00:01") assert len(hashes) == 1 assert hash_event(events[1]) in hashes def test_get_boundary_hashes_single_event(self): """ Given: - A single event. When: - Calling get_boundary_hashes. Then: - One hash is returned. """ events = [{"event_time": "2024-01-15T10:00:00", "domain": "a.com"}] hashes = get_boundary_hashes(events, "2024-01-15T10:00:00") assert len(hashes) == 1 # ─── get_events_command Tests ───────────────────────────────────────────────── class TestGetEventsCommand: def test_returns_readable_output(self, mock_client: Client, mocker): """ Given: - One web log event is available. When: - Calling get_events_command with should_push_events=False. Then: - CommandResults with readable output containing "Menlo" is returned. """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) results = get_events_command( client=mock_client, args={"start_time": "1 hour", "end_time": "now", "log_types": "web", "limit": "5000"}, log_types=["web"], max_events_per_fetch_per_type=5000, ) assert results.readable_output is not None assert "Menlo" in results.readable_output def test_no_enrichment_when_not_pushing(self, mock_client: Client, mocker): """ Given: - should_push_events=False. When: - Calling get_events_command. Then: - Events in raw_response do NOT have _time or source_log_type fields. """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) results = get_events_command( client=mock_client, args={"start_time": "1 hour", "end_time": "now", "log_types": "web", "should_push_events": "False"}, log_types=["web"], max_events_per_fetch_per_type=5000, ) raw = results.raw_response assert isinstance(raw, list) assert len(raw) == 1 assert "_time" not in raw[0] assert "source_log_type" not in raw[0] def test_enrichment_when_pushing(self, mock_client: Client, mocker): """ Given: - should_push_events=True. When: - Calling get_events_command. Then: - Events in raw_response have _time and source_log_type fields. - A push confirmation message is included. """ mocker.patch.object(mock_client, "fetch_log_page", side_effect=[make_web_response(), make_empty_response()]) mocker.patch("MenloSecurity.send_events_to_xsiam") results = get_events_command( client=mock_client, args={"start_time": "1 hour", "end_time": "now", "log_types": "web", "should_push_events": "True"}, log_types=["web"], max_events_per_fetch_per_type=5000, ) # When pushing, returns [table_results, push_message]. assert isinstance(results, list) raw = results[0].raw_response assert isinstance(raw, list) assert len(raw) == 1 assert "_time" in raw[0] assert raw[0]["source_log_type"] == "web_logs" assert "pushed" in results[1].readable_output.lower() def test_uses_default_log_types_when_not_specified(self, mock_client: Client, mocker): """ Given: - No log_types specified in args. When: - Calling get_events_command with default log_types=["web", "audit"]. Then: - fetch_log_page is called for both default log types. """ mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) get_events_command( client=mock_client, args={"start_time": "1 hour", "end_time": "now"}, log_types=["web", "audit"], max_events_per_fetch_per_type=5000, ) log_types_called = {call.kwargs["log_type"] for call in mock_fetch.call_args_list} assert "web" in log_types_called assert "audit" in log_types_called def test_raises_on_invalid_log_type(self, mock_client: Client, mocker): """ Given: - An invalid log type "invalid_type" in the command args. When: - Calling get_events_command. Then: - A ValueError is raised listing the invalid type and valid options. """ with pytest.raises(ValueError, match="Unknown log type.*invalid_type"): get_events_command( client=mock_client, args={"start_time": "1 hour", "end_time": "now", "log_types": "invalid_type"}, log_types=["web"], max_events_per_fetch_per_type=100, ) # ─── test_module Tests ──────────────────────────────────────────────────────── class TestTestModule: def test_returns_ok_when_all_log_types_succeed(self, mock_client: Client, mocker): """ Given: - Two log types configured: web and audit. When: - Calling test_module. Then: - Returns "ok" and makes one API request per log type. """ from MenloSecurity import test_module # noqa: PLC0415 mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) result = test_module(mock_client, ["web", "audit"]) assert result == "ok" assert mock_fetch.call_count == 2 def test_tests_all_configured_log_types(self, mock_client: Client, mocker): """ Given: - Three log types configured: web, audit, dlp. When: - Calling test_module. Then: - One API request is made per log type with the correct log_type value. """ from MenloSecurity import test_module # noqa: PLC0415 mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) test_module(mock_client, ["web", "audit", "dlp"]) assert mock_fetch.call_count == 3 log_types_called = {call.kwargs["log_type"] for call in mock_fetch.call_args_list} assert "web" in log_types_called assert "audit" in log_types_called assert "dlp" in log_types_called def test_safemail_sends_email_api_type(self, mock_client: Client, mocker): """ Given: - "safemail" is the configured log type. When: - Calling test_module. Then: - fetch_log_page is called with log_type="email". """ from MenloSecurity import test_module # noqa: PLC0415 mock_fetch = mocker.patch.object(mock_client, "fetch_log_page", return_value=make_empty_response()) test_module(mock_client, ["safemail"]) assert mock_fetch.call_args.kwargs["log_type"] == "email" def test_returns_auth_error_on_401(self, mock_client: Client, mocker): """ Given: - fetch_log_page raises an exception containing "401". When: - Calling test_module. Then: - A descriptive "Authorization Error" string is returned (not raised). """ from MenloSecurity import test_module # noqa: PLC0415 mocker.patch.object(mock_client, "fetch_log_page", side_effect=Exception("401 Unauthorized")) result = test_module(mock_client, ["web"]) assert "Authorization Error" in result assert "Auth Token" in result def test_returns_auth_error_on_403(self, mock_client: Client, mocker): """ Given: - fetch_log_page raises an exception containing "403". When: - Calling test_module. Then: - A descriptive "Authorization Error" string is returned. """ from MenloSecurity import test_module # noqa: PLC0415 mocker.patch.object(mock_client, "fetch_log_page", side_effect=Exception("403 Forbidden")) result = test_module(mock_client, ["web"]) assert "Authorization Error" in result def test_raises_on_unexpected_error(self, mock_client: Client, mocker): """ Given: - fetch_log_page raises an unexpected exception (500). When: - Calling test_module. Then: - The exception is re-raised (not swallowed). """ from MenloSecurity import test_module # noqa: PLC0415 mocker.patch.object(mock_client, "fetch_log_page", side_effect=Exception("500 Internal Server Error")) with pytest.raises(Exception, match="500"): test_module(mock_client, ["web"]) def test_returns_connection_error_message(self, mock_client: Client, mocker): """ Given: - fetch_log_page raises a ConnectionError. When: - Calling test_module. Then: - Returns a user-friendly connection error message. """ from MenloSecurity import test_module # noqa: PLC0415 mocker.patch.object(mock_client, "fetch_log_page", side_effect=Exception("ConnectionError: Failed to establish")) result = test_module(mock_client, ["web"]) assert "Connection Error" in result class TestFetchEventsCommand: """Tests for the scheduled fetch-events orchestration (producer/consumer send + setLastRun). The command spawns a real consumer thread that drains a queue and calls send_events_to_xsiam. We patch ``fetch_events`` (the producer) to push pages via the ``on_page`` callback and return a controlled next_run, and patch ``send_events_to_xsiam`` so no real network call is made. """ @staticmethod def _make_producer(next_run: dict, pages: list[list[dict]]): """Return a fake fetch_events that streams ``pages`` to on_page and returns ``next_run``.""" def _fake_fetch_events(*, on_page=None, **_kwargs): if on_page is not None: for page in pages: on_page(page) return dict(next_run), [] # copy so the command's pop() doesn't mutate our fixture return _fake_fetch_events def test_caught_up_persists_state_without_next_trigger(self, mock_client: Client, mocker): """ Given: - fetch_events reports caught up (no nextTrigger) and streams one page. When: - Calling fetch_events_command. Then: - The page is sent to XSIAM, state is persisted via setLastRun, and no nextTrigger is set. """ mocker.patch("MenloSecurity.demisto.getLastRun", return_value={}) set_last_run = mocker.patch("MenloSecurity.demisto.setLastRun") send = mocker.patch("MenloSecurity.send_events_to_xsiam") mocker.patch( "MenloSecurity.fetch_events", side_effect=self._make_producer( {"web": {"last_fetch_time": "2024-01-15T10:00:00Z", "boundary_hashes": []}}, pages=[[{"event_time": "2024-01-15T10:00:00Z"}]], ), ) fetch_events_command( client=mock_client, log_types=["web"], first_fetch_time="5 minutes", max_events_per_fetch_per_type=5000, ) send.assert_called_once() # the streamed page was sent last_call = set_last_run.call_args_list[-1].args[0] assert "nextTrigger" not in last_call assert last_call["web"]["last_fetch_time"] == "2024-01-15T10:00:00Z" def test_behind_persists_next_trigger_for_engine_redispatch(self, mock_client: Client, mocker): """ Given: - fetch_events reports "behind" (next_run carries nextTrigger=0). When: - Calling fetch_events_command. Then: - setLastRun persists nextTrigger=0 so the engine immediately re-dispatches the command. """ mocker.patch("MenloSecurity.demisto.getLastRun", return_value={}) set_last_run = mocker.patch("MenloSecurity.demisto.setLastRun") mocker.patch("MenloSecurity.send_events_to_xsiam") mocker.patch( "MenloSecurity.fetch_events", side_effect=self._make_producer( {"web": {"last_fetch_time": "2024-01-15T09:05:00Z", "boundary_hashes": []}, "nextTrigger": "0"}, pages=[[{"event_time": "2024-01-15T09:05:00Z"}]], ), ) fetch_events_command( client=mock_client, log_types=["web"], first_fetch_time="5 minutes", max_events_per_fetch_per_type=5000, ) last_call = set_last_run.call_args_list[-1].args[0] assert last_call.get("nextTrigger") == "0" def test_send_error_aborts_without_persisting_state(self, mock_client: Client, mocker): """ Given: - send_events_to_xsiam raises inside the consumer thread. When: - Calling fetch_events_command. Then: - The error propagates (main() converts it to return_error) and state is NOT persisted, so the next cycle re-fetches from the previous boundary (dedup handles overlap). """ mocker.patch("MenloSecurity.demisto.getLastRun", return_value={"web": {"last_fetch_time": "2024-01-15T09:00:00Z"}}) set_last_run = mocker.patch("MenloSecurity.demisto.setLastRun") # The consumer logs the failure via demisto.error (writes to stdout); mock it so the test # harness's "no stdout output" check stays happy while we assert on the raised exception. mocker.patch("MenloSecurity.demisto.error") mocker.patch("MenloSecurity.send_events_to_xsiam", side_effect=RuntimeError("XSIAM send failed")) mocker.patch( "MenloSecurity.fetch_events", side_effect=self._make_producer( {"web": {"last_fetch_time": "2024-01-15T09:05:00Z", "boundary_hashes": []}}, pages=[[{"event_time": "2024-01-15T09:05:00Z"}]], ), ) with pytest.raises(RuntimeError, match="XSIAM send failed"): fetch_events_command( client=mock_client, log_types=["web"], first_fetch_time="5 minutes", max_events_per_fetch_per_type=5000, ) set_last_run.assert_not_called() # state must NOT advance past a failed send def test_non_streaming_fetch_does_not_raise_and_returns_events(self, mock_client: Client, mocker): """ Given: - fetch_events is called WITHOUT an on_page callback (the get-events path). When: - _fetch_log_type_task runs and registers no streaming callback. Then: - The _counting_callback guard (``if on_page is not None``) keeps the non-streaming branch safe: no exception is raised and the accumulated events are returned in the list. """ mocker.patch.object(mock_client, "fetch_log_page", return_value=make_web_response()) next_run, events = fetch_events( client=mock_client, last_run={}, log_types=["web"], first_fetch_time="1 hour", max_events_per_fetch_per_type=1, # cap at 1 so the single-event page isn't re-paged ) # No exception was raised (the _counting_callback guard kept the non-streaming branch safe) # and the accumulated events are returned in the list. assert len(events) == 1 assert "web" in next_run def test_hung_consumer_raises_and_does_not_persist_state(self, mock_client: Client, mocker): """ Given: - The consumer thread is still alive after join() (a hung mid-send). When: - Calling fetch_events_command. Then: - A RuntimeError is raised and setLastRun is NOT called, so advanced timestamps are not persisted for events that may not have been ingested (prevents data loss). """ mocker.patch("MenloSecurity.demisto.getLastRun", return_value={}) set_last_run = mocker.patch("MenloSecurity.demisto.setLastRun") mocker.patch("MenloSecurity.send_events_to_xsiam") mocker.patch( "MenloSecurity.fetch_events", side_effect=self._make_producer( {"web": {"last_fetch_time": "2024-01-15T10:00:00Z", "boundary_hashes": []}}, pages=[[{"event_time": "2024-01-15T10:00:00Z"}]], ), ) # Replace the consumer thread with a fake whose is_alive() always reports True, so the # command takes the "consumer did not exit" guard branch. fake_consumer = mocker.MagicMock() fake_consumer.is_alive.return_value = True mocker.patch("MenloSecurity.threading.Thread", return_value=fake_consumer) with pytest.raises(RuntimeError, match="did not exit within 120s"): fetch_events_command( client=mock_client, log_types=["web"], first_fetch_time="5 minutes", max_events_per_fetch_per_type=5000, ) set_last_run.assert_not_called() # state must NOT advance when the consumer is hung class TestPageHelpers: """Tests for the page-processing helpers: _normalize_page, _enrich_events, _drop_boundary_duplicates.""" def test_normalize_page_list_of_wrappers(self): response = [ {"result": {"events": [{"event": {"a": 1}}], "pagingIdentifiers": {"next_time": "t1"}}}, {"result": {"events": [{"event": {"a": 2}}], "pagingIdentifiers": {"next_time": "t2"}}}, ] events, next_paging = _normalize_page(response) assert len(events) == 2 assert next_paging == {"next_time": "t2"} # last non-empty cursor wins def test_normalize_page_single_object(self): response = {"result": {"events": [{"event": {"a": 1}}], "pagingIdentifiers": {"next_time": "t1"}}} events, next_paging = _normalize_page(response) assert len(events) == 1 assert next_paging == {"next_time": "t1"} def test_normalize_page_empty_paging_returns_none(self): response = {"result": {"events": [{"event": {"a": 1}}], "pagingIdentifiers": {}}} _, next_paging = _normalize_page(response) assert next_paging is None def test_enrich_events_adds_time_and_source(self): page = [{"event": {"event_time": "2026-05-26T17:20:28.090", "url": "x"}}] out = _enrich_events(page, "web", enrich=True) assert out[0]["_time"] == "2026-05-26T17:20:28Z" assert out[0]["source_log_type"] == "web_logs" def test_enrich_events_no_enrich_unwraps_only(self): page = [{"event": {"event_time": "2026-05-26T17:20:28.090", "url": "x"}}] out = _enrich_events(page, "web", enrich=False) assert "_time" not in out[0] assert "source_log_type" not in out[0] assert out[0]["url"] == "x" # still unwrapped from the {"event": {...}} envelope def test_drop_boundary_duplicates_removes_leading_matches(self): boundary_time = "2024-01-15T10:00:00" dup = {"event_time": boundary_time, "id": "a"} keep = {"event_time": "2024-01-15T10:00:01", "id": "b"} out = _drop_boundary_duplicates([dup, keep], boundary_time, {hash_event(dup)}) assert out == [keep] def test_drop_boundary_duplicates_keeps_non_matching(self): boundary_time = "2024-01-15T10:00:00" e1 = {"event_time": "2024-01-15T10:00:01", "id": "a"} out = _drop_boundary_duplicates([e1], boundary_time, {"someotherhash"}) assert out == [e1] # nothing dropped def test_enrich_events_unparseable_time_preserves_raw_string(self, mocker): """An unparseable event_time falls back to arg_to_datetime; when that also fails (returns None), the raw string is preserved in _time rather than raising.""" # Force the arg_to_datetime fallback to fail so we hit the raw-string branch. mocker.patch("MenloSecurity.arg_to_datetime", return_value=None) page = [{"event": {"event_time": "not-a-timestamp", "url": "x"}}] out = _enrich_events(page, "web", enrich=True) assert out[0]["_time"] == "not-a-timestamp" # raw value preserved, no exception assert out[0]["source_log_type"] == "web_logs" def test_enrich_events_missing_time_sets_source_but_no_time(self): """An event without event_time still gets source_log_type, but no _time is added.""" page = [{"event": {"url": "x"}}] out = _enrich_events(page, "web", enrich=True) assert "_time" not in out[0] assert out[0]["source_log_type"] == "web_logs"