MondayEventCollector
Collects Monday.com audit logs and activity events for Cortex XSIAM using OAuth 2.0 authentication.
Analytics & SIEM · Monday
Details
| ID | MondayEventCollector |
|---|---|
| Provider | Monday.com |
| Category | Analytics & SIEM |
| From Version | 8.3.0 |
| Docker Image | demisto/python3:3.12.13.10116658 |
| Supported Modules | XSIAM |
README
Collects Monday.com audit logs and activity events for Cortex XSIAM.
Required Permissions
To use this integration, the following permissions are required on the Monday.com app.
boards:read
Activity log
Can be accessed using the OAuth method.
Create your Monday app guidelines and make sure the needed permissions are granted for the app registration:
Required scope - boards:read
The Redirect URI - https://localhost
Enter your Client ID and Client Secret in the instance parameter fields.
Run the !monday-generate-login-url command in the War Room and follow the instructions:
To sign in, click the login URL and grant Cortex XSIAM permissions. You will be automatically redirected to a link with the following structure:
REDIRECT_URI?code=AUTH_CODE®ion=REGION&scope=boards%3Aread&state=
Copy the AUTH_CODE (without the code= prefix) and paste it in your instance configuration under the Authorization code parameter.
Save the instance.
In the Playground, run the !monday-auth-test command. A ‘Success’ message is generated.
Audit log
Generating the API token
To generate the audit log API token, access the admin section of your account, click the “Security” section, and then the “Audit” tab. From there, select the “Monitor by API” button and copy it.
Audit log is an advanced security feature and available on the Enterprise plan and can only be accessed by the account admin.
Configure MondayEventCollector in Cortex
| Parameter | Description | Required |
|---|---|---|
| Activity logs Server URL | False | |
| Client ID | False | |
| Client secret | False | |
| Authorization code | The code received from the redirect URL after running monday-generate-login-url command. (needed for Activity Logs only) | False |
| Board IDs | Comma separated list of board IDs. (needed for Activity Logs only) | False |
| Events Fetch Interval | False | |
| Maximum number of Activity Logs per board per fetch | False | |
| Audit Server URL | False | |
| Audit API token | In the Admin section of your account, click the ‘Security’ section and then the ‘Audit’ tab. Select the ‘Monitor by API’ button. | False |
| Maximum number of Audit Logs per fetch | False | |
| Fetch events | False | |
| Event types | True | |
| 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.
monday-generate-login-url
Generate the login url used for Authorization code flow.
Base Command
monday-generate-login-url
Input
There are no input arguments for this command.
Context Output
There is no context output for this command.
Command Example
!monday-generate-login-url
Human Readable Output
Authorization instructions
- To sign in, click the login URL and grant Cortex XSIAM permissions.
You will be automatically redirected to a link with the following structure:
REDIRECT_URI?code=AUTH_CODE®ion=REGION&scope=boards%3Aread&state=- Copy the
AUTH_CODE(without thecode=prefix) and paste it in your instance configuration under the Authorization code parameter.
monday-auth-test
Run this command to test the connectivity to Monday.
Base Command
monday-auth-test
Input
There are no input arguments for this command.
Context Output
There is no context output for this command.
Command Example
!monday-auth-test
Human Readable Output
✅ Success!
Configuration parameters
activity_logs_url— Activity logs Server URLcredentials— Client IDauth_code—board_ids— Board IDseventFetchInterval— Events Fetch Intervalmax_activity_logs_per_fetch— Maximum number of Activity Logs per board per fetchaudit_logs_url— Audit Server URLaudit_token—max_audit_logs_per_fetch— Maximum number of Audit Logs per fetchisFetchEvents— Fetch eventsselected_event_types— Event typesinsecure— Trust any certificate (not secure)proxy— Use system proxy settings
Commands (2)
-
monday-auth-testRun this command to test the connectivity to Monday.
-
monday-generate-login-urlGenerate the login url used for Authorization code flow.
import time import ast from unittest.mock import MagicMock from MondayEventCollector import ( get_audit_logs, generate_log_hash, subtract_epsilon_from_timestamp, fetch_audit_logs, fetch_activity_logs, test_connection as monday_test_connection, ) import demistomock as demisto # noqa: F401 from CommonServerPython import * class TestGetAuditLogs: """ per_page - The maximum activity logs per page is 1000. limit - The maximum limit can be fetched is 5000. - limit ≤ per_page -> per_page = limit -> fetch full page, remaining logs mechanism is not used. - limit > per_page -> per_page = 1000 -> fetch partial page, remaining logs mechanism is used. CRITICAL: per_page is ALWAYS constant during all the fetch audit logs runs. """ def test_get_audit_logs_with_continuing_fetch_when_limit_less_than_max_logs_per_page(self, mocker): """ Given: - assume maximum audit logs per fetch = 10 - limit = 8 - next_page is not None (next_page=2) When: get_audit_logs is called with per_page=limit=8 Then: It should fetch exactly 8 logs in one request and set continuing_fetch_info for remaining logs. full page fetched because the limit is less than the max logs per page-> no remaining logs from fetched partial page. """ limit = 8 mock_params = { "audit_logs_url": "https://test.monday.com", "audit_token": {"password": "test_token"}, } mocker.patch.object(demisto, "params", return_value=mock_params) mocker.patch.object(demisto, "debug") # --- FIRST FETCH: no continuing fetch mechanism, page=1 --- # Mock 8 logs from first page (per_page=8, but more logs available) page1_logs = [ {"timestamp": "2024-06-03T14:25:47.000Z", "account_id": 1, "event": "login"}, {"timestamp": "2024-06-03T14:25:47.000Z", "account_id": 2, "event": "login"}, # same timestamp as the newest log {"timestamp": "2024-06-03T14:20:47.000Z", "account_id": 3, "event": "logout"}, {"timestamp": "2024-06-03T14:15:47.000Z", "account_id": 4, "event": "login"}, {"timestamp": "2024-06-03T14:10:47.000Z", "account_id": 5, "event": "logout"}, {"timestamp": "2024-06-03T14:05:47.000Z", "account_id": 6, "event": "login"}, {"timestamp": "2024-06-03T14:00:47.000Z", "account_id": 7, "event": "login"}, {"timestamp": "2024-06-03T13:55:47.000Z", "account_id": 8, "event": "logout"}, ] mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "data": page1_logs, "page": 1, "per_page": limit, "next_page": 2, # More pages available } mocker.patch("MondayEventCollector.BaseClient._http_request", return_value=mock_response.json()) mock_timestamp_to_datestring = mocker.patch("MondayEventCollector.timestamp_to_datestring") mock_timestamp_to_datestring.return_value = "2024-06-03T15:00:00.000Z" # Create a mock AuditLogsClient mock_client = mocker.MagicMock() mock_client.get_audit_logs_request.return_value = mock_response.json() # Initial state - first fetch initial_last_run = {} now_ms = int(time.time() * 1000) result_logs, result_last_run = get_audit_logs( initial_last_run, now_ms, limit=limit, logs_per_page=limit, client=mock_client ) assert len(result_logs) == limit assert result_last_run["last_timestamp"] == page1_logs[0]["timestamp"] assert result_last_run["upper_bound_log_id"] == [ generate_log_hash(page1_logs[0]), generate_log_hash(page1_logs[1]), ] # Two newest logs with same timestamp assert result_last_run["continuing_fetch_info"] is not None # More pages available assert result_last_run["continuing_fetch_info"]["page"] == 2 # Verify only 1 request was made (limit reached <= 1000) assert mock_client.get_audit_logs_request.call_count == 1 # Verify request used per_page = limit (limit <= 1000) call_args = mock_client.get_audit_logs_request.call_args[0] assert call_args[2] == limit # logs_per_page parameter # --- SECOND FETCH: Continuing fetch mechanism, page=2--- # Mock page 2 logs (8 more logs with different timestamps) page2_logs = [ {"timestamp": "2024-06-03T13:50:47.000Z", "account_id": 9, "event": "login"}, {"timestamp": "2024-06-03T13:45:47.000Z", "account_id": 10, "event": "logout"}, {"timestamp": "2024-06-03T13:40:47.000Z", "account_id": 11, "event": "login"}, {"timestamp": "2024-06-03T13:35:47.000Z", "account_id": 12, "event": "logout"}, {"timestamp": "2024-06-03T13:30:47.000Z", "account_id": 13, "event": "login"}, {"timestamp": "2024-06-03T13:25:47.000Z", "account_id": 14, "event": "logout"}, {"timestamp": "2024-06-03T13:20:47.000Z", "account_id": 15, "event": "login"}, {"timestamp": "2024-06-03T13:15:47.000Z", "account_id": 16, "event": "logout"}, ] mock_response_page2 = MagicMock() mock_response_page2.status_code = 200 mock_response_page2.json.return_value = { "data": page2_logs, "page": 2, "per_page": limit, "next_page": None, # No more pages available } # Reset mock to return page 2 response mock_client.get_audit_logs_request.return_value = mock_response_page2.json() mock_client.reset_mock() previous_last_timestamp = result_last_run["last_timestamp"] previous_upper_bound_log_id = result_last_run["upper_bound_log_id"] continuing_last_run = result_last_run result_logs_2, result_last_run_2 = get_audit_logs(continuing_last_run, now_ms, limit, limit, client=mock_client) assert len(result_logs_2) == limit # Validate timestamps are in descending order (newest first) timestamps = [log["timestamp"] for log in result_logs_2] assert timestamps == sorted(timestamps, reverse=True) # last_timestamp does not change until new fetch starts (page=1) assert result_last_run_2["last_timestamp"] == previous_last_timestamp # upper_bound_log_id does not change until new fetch starts (page=1) assert result_last_run_2["upper_bound_log_id"] == previous_upper_bound_log_id # No more pages available assert result_last_run_2["continuing_fetch_info"] is None # on the last page, upper_bound_log_id set to lower_bound_log_id for the next fetch assert result_last_run_2["upper_bound_log_id"] == result_last_run_2["lower_bound_log_id"] # Validate API call parameters assert mock_client.get_audit_logs_request.call_count == 1 call_args_2 = mock_client.get_audit_logs_request.call_args[0] assert call_args_2[1] == 2 # page parameter - Should use page from continuing_fetch_info assert call_args_2[2] == limit # logs_per_page parameter # --- THIRD FETCH: New fetch with duplication handling --- # This tests the scenario where continuing_fetch_info is None and we start a new fetch range # The start_time should be last_timestamp minus epsilon to include logs with same timestamp as last_timestamp last_timestamp = result_last_run_2["last_timestamp"] # "2024-06-03T14:25:47.000Z" page1_logs_new_fetch = [ # New logs with different timestamps (should be included) {"timestamp": "2024-06-03T14:40:47.000Z", "account_id": 103, "event": "login"}, {"timestamp": "2024-06-03T14:35:47.000Z", "account_id": 104, "event": "logout"}, {"timestamp": "2024-06-03T14:30:47.000Z", "account_id": 105, "event": "login"}, {"timestamp": "2024-06-03T14:29:47.000Z", "account_id": 106, "event": "logout"}, # logs with same timestamp as last_timestamp but different account_ids (should be included) {"timestamp": last_timestamp, "account_id": 101, "event": "login"}, # New log with same timestamp {"timestamp": last_timestamp, "account_id": 102, "event": "logout"}, # New log with same timestamp # duplicates logs from previous fetch (should be filtered out by upper_bound_log_id, same timestamp as last_timestamp) page1_logs[0], # Duplicate (account_id=1 was in upper_bound) page1_logs[1], # Duplicate (account_id=2 was in upper_bound) ] mock_response_page1_new_fetch = MagicMock() mock_response_page1_new_fetch.status_code = 200 mock_response_page1_new_fetch.json.return_value = { "data": page1_logs_new_fetch, "page": 1, # New fetch starts from page 1 "per_page": limit, "next_page": None, # No more pages } # Reset mock for third fetch mock_client.get_audit_logs_request.return_value = mock_response_page1_new_fetch.json() mock_client.reset_mock() new_fetch_last_run = result_last_run_2 result_logs_3, result_last_run_3 = get_audit_logs(new_fetch_last_run, now_ms, limit, limit, client=mock_client) # Validate duplication handling - should get 6 logs (filtering out 2 duplicates) expected_logs = limit - 2 assert len(result_logs_3) == expected_logs # Verify no duplicate account_ids from upper_bound_log_id (account_ids 1 and 2 should be filtered) result_account_ids = {log["account_id"] for log in result_logs_3} assert page1_logs[0]["account_id"] not in result_account_ids assert page1_logs[1]["account_id"] not in result_account_ids # Verify logs with different timestamps are included (account_ids 103-106) expected_new_account_ids = {101, 102, 103, 104, 105, 106} assert result_account_ids == expected_new_account_ids # last_timestamp should be the newest timestamp assert result_last_run_3["last_timestamp"] == page1_logs_new_fetch[0]["timestamp"] # No more pages assert result_last_run_3["continuing_fetch_info"] is None # Validate API call assert mock_client.get_audit_logs_request.call_count == 1 call_args_3 = mock_client.get_audit_logs_request.call_args[0] assert call_args_3[1] == 1 assert call_args_3[2] == limit # Verify start_time filter includes epsilon adjustment time_filter_arg = call_args_3[0] filters = ast.literal_eval(time_filter_arg) assert filters["start_time"] == subtract_epsilon_from_timestamp(last_timestamp) def test_fetch_audit_logs_with_remaining_logs_mechanism(self, mocker): """ Test the complete remaining logs mechanism using fetch_audit_logs function. Given: - max_audit_logs_per_fetch = 8 (limit) - MAX_AUDIT_LOGS_PER_PAGE = 5 (per_page) - Available logs: 12 total (more than limit) When: fetch_audit_logs is called with limit > per_page Then: It should: 1. First call: fetch 5 logs (per_page=5), remaining=3 2. Second call: fetch 5 logs (per_page=5), but only use 3 to reach limit=8 3. Set excess_logs_info for the 2 unused logs from second call 4. Return exactly 8 logs total """ limit = 8 per_page = 5 mocker.patch("MondayEventCollector.MAX_AUDIT_LOGS_PER_PAGE", per_page) mocker.patch("MondayEventCollector.MAX_AUDIT_LOGS_PER_FETCH", limit) mock_params = { "audit_logs_url": "https://test.monday.com", "audit_token": {"password": "test_token"}, "max_audit_logs_per_fetch": limit, } mocker.patch.object(demisto, "params", return_value=mock_params) mocker.patch.object(demisto, "debug") # --- FIRST FETCH - FIRST API CALL: Page 1, fetch 5 logs --- page1_logs = [ {"timestamp": "2024-06-03T14:25:47.000Z", "account_id": 1, "event": "login"}, {"timestamp": "2024-06-03T14:25:47.000Z", "account_id": 2, "event": "logout"}, {"timestamp": "2024-06-03T14:20:47.000Z", "account_id": 3, "event": "login"}, {"timestamp": "2024-06-03T14:15:47.000Z", "account_id": 4, "event": "logout"}, {"timestamp": "2024-06-03T14:10:47.000Z", "account_id": 5, "event": "login"}, ] # --- FIRST FETCH - SECOND API CALL: Page 2, fetch 5 logs (but only need 3) --- page2_logs = [ {"timestamp": "2024-06-03T14:05:47.000Z", "account_id": 6, "event": "logout"}, {"timestamp": "2024-06-03T14:00:47.000Z", "account_id": 7, "event": "login"}, {"timestamp": "2024-06-03T13:55:47.000Z", "account_id": 8, "event": "logout"}, {"timestamp": "2024-06-03T13:50:47.000Z", "account_id": 9, "event": "login"}, # Excess log 1 {"timestamp": "2024-06-03T13:45:47.000Z", "account_id": 10, "event": "logout"}, # Excess log 2 ] # --- SECOND FETCH - THIRD API CALL: Page 3, fetch 5 logs page3_logs = [ {"timestamp": "2024-06-03T13:45:47.000Z", "account_id": 11, "event": "logout"}, {"timestamp": "2024-06-03T13:40:47.000Z", "account_id": 12, "event": "login"}, {"timestamp": "2024-06-03T13:35:47.000Z", "account_id": 13, "event": "logout"}, {"timestamp": "2024-06-03T13:30:47.000Z", "account_id": 14, "event": "login"}, {"timestamp": "2024-06-03T13:25:47.000Z", "account_id": 15, "event": "logout"}, ] # --- SECOND FETCH - FOURTH API CALL: Page 4, fetch 5 logs (but only need 1)--- page4_logs = [ {"timestamp": "2024-06-03T13:25:47.000Z", "account_id": 16, "event": "logout"}, {"timestamp": "2024-06-03T13:20:47.000Z", "account_id": 17, "event": "login"}, # Excess log 1 {"timestamp": "2024-06-03T13:15:47.000Z", "account_id": 18, "event": "logout"}, # Excess log 2 {"timestamp": "2024-06-03T13:10:47.000Z", "account_id": 19, "event": "login"}, # Excess log 3 {"timestamp": "2024-06-03T13:05:47.000Z", "account_id": 20, "event": "logout"}, # Excess log 4 ] page_1_1_logs = [ {"timestamp": "2024-06-03T14:35:47.000Z", "account_id": 21, "event": "login"}, {"timestamp": "2024-06-03T14:30:47.000Z", "account_id": 22, "event": "logout"}, ] # Mock responses for both API calls mock_response_page1 = MagicMock() mock_response_page1.status_code = 200 mock_response_page1.json.return_value = {"data": page1_logs, "page": 1, "per_page": per_page, "next_page": 2} mock_response_page2 = MagicMock() mock_response_page2.status_code = 200 mock_response_page2.json.return_value = {"data": page2_logs, "page": 2, "per_page": per_page, "next_page": 3} mock_response_page3 = MagicMock() mock_response_page3.status_code = 200 mock_response_page3.json.return_value = {"data": page3_logs, "page": 3, "per_page": per_page, "next_page": 4} mock_response_page4 = MagicMock() mock_response_page4.status_code = 200 mock_response_page4.json.return_value = {"data": page4_logs, "page": 4, "per_page": per_page, "next_page": None} mock_response_page1_1 = MagicMock() mock_response_page1_1.status_code = 200 mock_response_page1_1.json.return_value = {"data": page_1_1_logs, "page": 1, "per_page": per_page, "next_page": None} # Configure mock to return different responses for each call mock_requests_get = mocker.patch( "MondayEventCollector.BaseClient._http_request", side_effect=[ mock_response_page1.json(), mock_response_page2.json(), mock_response_page2.json(), mock_response_page3.json(), mock_response_page4.json(), mock_response_page4.json(), mock_response_page1_1.json(), ], ) mock_timestamp_to_datestring = mocker.patch("MondayEventCollector.timestamp_to_datestring") mock_timestamp_to_datestring.return_value = "2024-06-03T15:00:00.000Z" mocker.patch("MondayEventCollector.add_fields_to_events") # Initial state - first fetch initial_last_run = {} result_last_run, result_logs = fetch_audit_logs(initial_last_run) assert len(result_logs) == limit assert result_last_run["last_timestamp"] == page1_logs[0]["timestamp"] assert result_last_run["upper_bound_log_id"] == [ generate_log_hash(page1_logs[0]), generate_log_hash(page1_logs[1]), ] # Two newest logs with same timestamp # Should contain logs from both pages (first 5 + first 3 from second page) expected_account_ids = [1, 2, 3, 4, 5, 6, 7, 8] actual_account_ids = [log["account_id"] for log in result_logs] assert actual_account_ids == expected_account_ids # Should make exactly 2 API calls (5 logs from page 1 + 3 logs from page 2) assert mock_requests_get.call_count == 2 # Should set excess_logs_info for unused logs from page 2 assert result_last_run["excess_logs_info"] is not None excess_info = result_last_run["excess_logs_info"] assert excess_info["page"] == 2 assert excess_info["offset"] == 3 # Only first 3 logs from page 2 were used # --- SECOND CALL TO fetch_audit_logs: Process excess logs from previous fetch before fetching new page --- # Second fetch using last_run from first fetch result_last_run_2, result_logs_2 = fetch_audit_logs(result_last_run) assert len(result_logs_2) == limit # Should contain excess logs first (9, 10), then new logs (11-16) expected_account_ids_2 = [9, 10, 11, 12, 13, 14, 15, 16] # 2 excess + 6 new actual_account_ids_2 = [log["account_id"] for log in result_logs_2] assert actual_account_ids_2 == expected_account_ids_2 # Upper bound log id should not change until starting a new fetch window (page=1) assert result_last_run_2["upper_bound_log_id"] == result_last_run["upper_bound_log_id"] # Second fetch should make exactly 3 API calls(2 logs from page 2 + 5 logs from page 3 + 1 log from page 4) # (First fetch made 2 API calls) assert mock_requests_get.call_count == 5 # Should set excess_logs_info for unused logs from page 4 assert result_last_run_2["excess_logs_info"] is not None excess_info = result_last_run_2["excess_logs_info"] assert excess_info["page"] == 4 assert excess_info["offset"] == 1 # Only first 1 log from page 4 were used # Third fetch using last_run from second fetch result_last_run_3, result_logs_3 = fetch_audit_logs(result_last_run_2) # Should return exactly 6 logs assert len(result_logs_3) == 6 expected_account_ids_3 = [17, 18, 19, 20, 21, 22] actual_account_ids_3 = [log["account_id"] for log in result_logs_3] assert actual_account_ids_3 == expected_account_ids_3 # lower bound log id should set with the upper bound log id when reaching the last page assert result_last_run_3["upper_bound_log_id"] == result_last_run_3["lower_bound_log_id"] # Third fetch should make exactly 1 API calls (4 logs remaining from page 5) # (First fetch made 2 API calls, second fetch made 3 API calls) assert mock_requests_get.call_count == 7 # Should remove excess_logs_info from last run (already fetched remaining logs) assert result_last_run_3["excess_logs_info"] is None # Should remove continuing_fetch_info from last run (next page is none, next fetch will start a new fetch window) assert result_last_run_3["continuing_fetch_info"] is None assert result_last_run_3["upper_bound_log_id"] == [generate_log_hash(page_1_1_logs[0])] class TestGetActivityLogs: """ per_page - The maximum activity logs per page is 10000. limit - The maximum limit can be fetched is 10000. - limit ≤ per_page -> per_page = limit -> always fetch full page, remaining logs mechanism is not used. - limit > per_page -> no support for this case. limit equals to per_page. CRITICAL: per_page is ALWAYS constant during all the fetch activity logs runs. Test class for activity logs functionality. """ def test_fetch_activity_logs_with_continuing_fetch_multi_board(self, mocker): """ Test continuing fetch mechanism for activity logs across multiple boards. Given: - 2 boards: "123", "456" - limit = 8 per board - Board 123: 15 logs available (needs continuing fetch) - Board 456: 5 logs available (completes in one fetch) When: fetch_activity_logs is called twice Then: First call: - Board 123: Gets 8 logs, sets continuing_fetch_info - Board 456: Gets 5 logs, completes Second call: - Board 123: Gets remaining 7 logs, completes - Board 456: No new logs """ limit = 8 board_ids = "123,456" mocker.patch("MondayEventCollector.MAX_ACTIVITY_LOGS_PER_FETCH", 10) mock_params = { "activity_logs_url": "https://api.monday.com", "board_ids": board_ids, "max_activity_logs_per_fetch": limit, } mocker.patch.object(demisto, "params", return_value=mock_params) mocker.patch.object(demisto, "debug") mocker.patch("MondayEventCollector.get_access_token", return_value="mock_access_token") # --- BOARD 123 LOGS --- # First fetch: 8 logs (limit reached, more available) board_123_page1_logs = [ {"id": "log1", "event": "update_column", "created_at": "17545145534156780", "data": "{}"}, {"id": "log2", "event": "update_column", "created_at": "17545145534156780", "data": "{}"}, # Same timestamp {"id": "log3", "event": "create_item", "created_at": "17545145530000000", "data": "{}"}, {"id": "log4", "event": "update_status", "created_at": "17545145520000000", "data": "{}"}, {"id": "log5", "event": "delete_item", "created_at": "17545145510000000", "data": "{}"}, {"id": "log6", "event": "create_group", "created_at": "17545145500000000", "data": "{}"}, {"id": "log7", "event": "update_name", "created_at": "17545145490000000", "data": "{}"}, {"id": "log8", "event": "create_board", "created_at": "17545145480000000", "data": "{}"}, ] # Second fetch: remaining 7 logs board_123_page2_logs = [ {"id": "log9", "event": "update_column", "created_at": "17545145470000000", "data": "{}"}, {"id": "log10", "event": "delete_group", "created_at": "17545145460000000", "data": "{}"}, {"id": "log11", "event": "create_column", "created_at": "17545145450000000", "data": "{}"}, {"id": "log12", "event": "update_board", "created_at": "17545145440000000", "data": "{}"}, {"id": "log13", "event": "create_item", "created_at": "17545145430000000", "data": "{}"}, {"id": "log14", "event": "update_status", "created_at": "17545145420000000", "data": "{}"}, {"id": "log15", "event": "delete_column", "created_at": "17545145410000000", "data": "{}"}, ] # --- BOARD 456 LOGS --- # First fetch: 5 logs (completes) board_456_page1_logs = [ {"id": "log16", "event": "create_board", "created_at": "17545145400000000", "data": "{}"}, {"id": "log17", "event": "update_name", "created_at": "17545145390000000", "data": "{}"}, {"id": "log18", "event": "create_group", "created_at": "17545145380000000", "data": "{}"}, {"id": "log19", "event": "create_item", "created_at": "17545145370000000", "data": "{}"}, {"id": "log20", "event": "update_column", "created_at": "17545145360000000", "data": "{}"}, ] # Mock GraphQL responses for sequential API calls # First fetch: mock_response_board123_page1 = {"data": {"boards": [{"activity_logs": board_123_page1_logs}]}} mock_response_board123_page2_check = {"data": {"boards": [{"activity_logs": board_123_page2_logs}]}} mock_response_board456_page1 = {"data": {"boards": [{"activity_logs": board_456_page1_logs}]}} mock_response_board456_page2_empty = {"data": {"boards": [{"activity_logs": []}]}} # Second fetch: mock_response_board123_page2 = {"data": {"boards": [{"activity_logs": board_123_page2_logs}]}} mock_response_board123_page3_empty = {"data": {"boards": [{"activity_logs": []}]}} mock_response_board456_page1_empty = { # minus epsilon adjusted timestamp - no new logs, only duplicate logs "data": { "boards": [ { "activity_logs": [ {"id": "log16", "event": "create_board", "created_at": "17545145400000000", "data": "{}"}, ] } ] } } mock_response_board456_page2_empty_2 = {"data": {"boards": [{"activity_logs": []}]}} # Configure mock to return different responses for each call mock_http_request = mocker.patch("MondayEventCollector.BaseClient._http_request") mock_http_request.side_effect = [ # First fetch_activity_logs call: mock_response_board123_page1, # Board 123, page 1 (8 logs) mock_response_board123_page2_check, # Board 123, page 2 check (has logs) mock_response_board456_page1, # Board 456, page 1 (5 logs) mock_response_board456_page2_empty, # Board 456, page 2 check (empty) # Second fetch_activity_logs call: mock_response_board123_page2, # Board 123, page 2 (7 logs) mock_response_board123_page3_empty, # Board 123, page 3 check (empty) mock_response_board456_page1_empty, # Board 456, page 1 (no new logs) mock_response_board456_page2_empty_2, # Board 456, page 2 check (empty) ] mocker.patch("MondayEventCollector.add_fields_to_events") mocker.patch("MondayEventCollector.extract_activity_log_data", side_effect=lambda x: x) mocker.patch("MondayEventCollector.convert_timestamp", return_value="2024-06-03T14:25:47.000Z") initial_last_run = {} result_last_run_1, result_logs_1 = fetch_activity_logs(initial_last_run) # Check fetched logs assert len(result_logs_1) == 13 expected_log_ids = { "log1", "log2", "log3", "log4", "log5", "log6", "log7", "log8", # Board 123 "log16", "log17", "log18", "log19", "log20", } # Board 456 actual_log_ids = {log.get("id") for log in result_logs_1} assert actual_log_ids == expected_log_ids, f"Expected IDs: {expected_log_ids}, but got: {actual_log_ids}" # Check last run assert "123" in result_last_run_1 assert "456" in result_last_run_1 assert len(result_last_run_1) == 2 # Board 123 should have continuing_fetch_info (more logs available) board_123_last_run = result_last_run_1["123"] assert board_123_last_run["continuing_fetch_info"] is not None assert board_123_last_run["continuing_fetch_info"]["page"] == 2 assert board_123_last_run["upper_bound_log_id"] == ["log1", "log2"] # Same timestamp logs # Board 456 should be complete (no continuing_fetch_info) board_456_last_run = result_last_run_1["456"] assert board_456_last_run["continuing_fetch_info"] is None assert board_456_last_run["upper_bound_log_id"] == ["log16"] assert board_456_last_run["lower_bound_log_id"] == board_456_last_run["upper_bound_log_id"] # Start second fetch using last_run from first fetch result_last_run_2, result_logs_2 = fetch_activity_logs(result_last_run_1) # Should return 7 logs (remaining from board 123, board 456 has no new logs) assert len(result_logs_2) == 7 # Check fetched logs expected_log_ids = { "log9", "log10", "log11", "log12", "log13", "log14", "log15", } # only board 123 (board 456 has no new logs) actual_log_ids = {log.get("id") for log in result_logs_2} assert actual_log_ids == expected_log_ids, f"Expected IDs: {expected_log_ids}, but got: {actual_log_ids}" # Board 123 should now be complete board_123_last_run_2 = result_last_run_2["123"] assert board_123_last_run_2["continuing_fetch_info"] is None assert board_123_last_run_2["lower_bound_log_id"] == board_123_last_run_2["upper_bound_log_id"] # Board 456 should remain unchanged (no new logs) board_456_last_run_2 = result_last_run_2["456"] assert board_456_last_run_2["continuing_fetch_info"] is None # Validate total API calls made for first two fetches assert mock_http_request.call_count == 8 # --- THIRD FETCH: New fetch with duplication handling --- # This tests the scenario where continuing_fetch_info is None and we start a new fetch range # The start_time should be last_timestamp minus epsilon to include logs with same timestamp as last_timestamp # New logs for third fetch - Board 123 board_123_new_logs = [ # New logs with different timestamps (should be included) {"id": "log21", "event": "create_board", "created_at": "17545145540000000", "data": "{}"}, # Newer timestamp {"id": "log22", "event": "update_name", "created_at": "17545145535000000", "data": "{}"}, # Newer timestamp # These logs have same timestamp as last_timestamp but different IDs (should be included) {"id": "log23", "event": "create_item", "created_at": "17545145534156780", "data": "{}"}, # Same timestamp as log1 {"id": "log24", "event": "update_status", "created_at": "17545145534156780", "data": "{}"}, # Same timestamp as log1 # These would be duplicates from previous fetch (should be filtered out by upper_bound_log_id) {"id": "log1", "event": "update_column", "created_at": "17545145534156780", "data": "{}"}, # Duplicate {"id": "log2", "event": "update_column", "created_at": "17545145534156780", "data": "{}"}, # Duplicate ] # New logs for third fetch - Board 456 board_456_new_logs = [ # New logs with different timestamps (should be included) {"id": "log25", "event": "create_group", "created_at": "17545145404156780", "data": "{}"}, # Newer timestamp # Log with same timestamp as last_timestamp but different ID (should be included) {"id": "log26", "event": "update_column", "created_at": "17545145400000000", "data": "{}"}, # Same timestamp as log16 # This would be duplicate from previous fetch (should be filtered out) {"id": "log16", "event": "create_board", "created_at": "17545145400000000", "data": "{}"}, # Duplicate ] # Mock responses for third fetch mock_response_board123_new_fetch = {"data": {"boards": [{"activity_logs": board_123_new_logs}]}} mock_response_board123_page2_empty_new = {"data": {"boards": [{"activity_logs": []}]}} mock_response_board456_new_fetch = {"data": {"boards": [{"activity_logs": board_456_new_logs}]}} mock_response_board456_page2_empty_new = {"data": {"boards": [{"activity_logs": []}]}} # Reset and configure mock for third fetch mock_http_request.reset_mock() mock_http_request.side_effect = [ mock_response_board123_new_fetch, # Board 123, page 1 (new logs) mock_response_board123_page2_empty_new, # Board 123, page 2 check (empty) mock_response_board456_new_fetch, # Board 456, page 1 (new logs) mock_response_board456_page2_empty_new, # Board 456, page 2 check (empty) ] result_last_run_3, result_logs_3 = fetch_activity_logs(result_last_run_2) # Should return 6 logs total (4 from board 123-filtering out 2 duplicates + 2 from board 456-filtering out 1 duplicate) expected_logs_count = 6 assert len(result_logs_3) == expected_logs_count # Verify no duplicate IDs from upper_bound_log_id are included result_log_ids = {log.get("id") for log in result_logs_3} assert "log1" not in result_log_ids # Duplicate from board 123 assert "log2" not in result_log_ids # Duplicate from board 123 assert "log16" not in result_log_ids # Duplicate from board 456 # These should be included (new logs) expected_new_log_ids = {"log21", "log22", "log23", "log24", "log25", "log26"} assert result_log_ids == expected_new_log_ids, f"Expected IDs: {expected_new_log_ids}, but got: {result_log_ids}" board_123_last_run_3 = result_last_run_3["123"] board_456_last_run_3 = result_last_run_3["456"] # Both boards should be complete (no continuing_fetch_info) assert board_123_last_run_3["continuing_fetch_info"] is None assert board_456_last_run_3["continuing_fetch_info"] is None # Validate new timestamps are set correctly (newest log from each board) assert board_123_last_run_3["last_timestamp"] == "2024-06-03T14:25:47.000Z" assert board_456_last_run_3["last_timestamp"] == "2024-06-03T14:25:47.000Z" # Validate upper_bound_log_id is set to newest logs assert board_123_last_run_3["upper_bound_log_id"] == ["log21"] # Newest log from board 123 assert board_456_last_run_3["upper_bound_log_id"] == ["log25"] # Newest log from board 456 # Validate lower_bound_log_id is set for next fetch assert board_123_last_run_3["lower_bound_log_id"] == board_123_last_run_3["upper_bound_log_id"] assert board_456_last_run_3["lower_bound_log_id"] == board_456_last_run_3["upper_bound_log_id"] # Validate total API calls for third fetch class TestConnectionAndUtilities: """Test class for connection testing and utility functions.""" def test_generate_login_url_success(self, mocker): """Test successful generation of login URL.""" from MondayEventCollector import generate_login_url mock_params = {"credentials": {"identifier": "test_client_id"}} mocker.patch.object(demisto, "params", return_value=mock_params) result = generate_login_url() assert "https://auth.monday.com/oauth2/authorize?client_id=test_client_id" in result.readable_output assert "Click on the [login URL]" in result.readable_output assert "AUTH_CODE" in result.readable_output def test_generate_login_url_missing_client_id(self, mocker): """Test generate_login_url with missing client ID.""" from MondayEventCollector import generate_login_url mock_params = {"client_id": ""} mocker.patch.object(demisto, "params", return_value=mock_params) mocker.patch.object(demisto, "debug") try: generate_login_url() except DemistoException as e: assert "Please provide Client ID" in str(e) def test_test_connection_activity_logs_success(self, mocker): """Test successful connection test for activity logs.""" mock_params = { "client_id": "test_client_id", "secret": "test_secret", "auth_code": "test_auth_code", "board_ids": "123,456", "activity_logs_url": "https://api.monday.com", } mocker.patch.object(demisto, "params", return_value=mock_params) mocker.patch.object(demisto, "debug") # Mock ActivityLogsClient with required attributes mock_activity_client = mocker.MagicMock() mock_activity_client.client_id = "test_client_id" mock_activity_client.client_secret = "test_secret" mock_activity_client.auth_code = "test_auth_code" # Mock AuditLogsClient with required attributes mock_audit_client = mocker.MagicMock() mock_audit_client.audit_token = "" mock_audit_client.audit_logs_url = "" # Mock the client initialization functions mocker.patch("MondayEventCollector.initiate_activity_client", return_value=mock_activity_client) mocker.patch("MondayEventCollector.initiate_audit_client", return_value=mock_audit_client) # Mock get_integration_context to return access token mocker.patch("MondayEventCollector.get_integration_context", return_value={"access_token": {"password": "test_token"}}) # Mock get_activity_logs to simulate successful fetch mocker.patch("MondayEventCollector.get_activity_logs", return_value=([{"id": "test_log"}], {})) result = monday_test_connection() assert "Test connection success for activity logs" in result.readable_output def test_test_connection_audit_logs_success(self, mocker): """Test successful connection test for audit logs.""" mock_params = {"audit_token": "test_audit_token", "audit_logs_url": "https://test.monday.com"} mocker.patch.object(demisto, "params", return_value=mock_params) mocker.patch.object(demisto, "debug") # Mock ActivityLogsClient with empty attributes (will fail activity logs test) mock_activity_client = mocker.MagicMock() mock_activity_client.client_id = "" mock_activity_client.client_secret = "" # Mock AuditLogsClient with required attributes mock_audit_client = mocker.MagicMock() mock_audit_client.audit_token = "test_audit_token" mock_audit_client.audit_logs_url = "https://test.monday.com" # Mock the client initialization functions mocker.patch("MondayEventCollector.initiate_activity_client", return_value=mock_activity_client) mocker.patch("MondayEventCollector.initiate_audit_client", return_value=mock_audit_client) # Mock get_audit_logs to simulate successful fetch mocker.patch("MondayEventCollector.get_audit_logs", return_value=([{"timestamp": "2024-01-01T00:00:00.000Z"}], {})) result = monday_test_connection() assert "Test connection success for audit logs" in result.readable_output def test_test_connection_missing_params(self, mocker): """Test connection test with missing parameters.""" # Using the renamed import monday_test_connection mock_params = {} # No parameters provided mocker.patch.object(demisto, "params", return_value=mock_params) mocker.patch.object(demisto, "debug") result = monday_test_connection() assert "Please provide" in result.readable_output assert "to test connection" in result.readable_output def test_subtract_epsilon_from_timestamp(self): """Test timestamp epsilon subtraction utility function.""" timestamp = "2024-06-03T14:25:47.000Z" result = subtract_epsilon_from_timestamp(timestamp) assert result == "2024-06-03T14:25:46.999000Z" timestamp = "2024-06-03T14:25:00.000Z" result = subtract_epsilon_from_timestamp(timestamp) assert result == "2024-06-03T14:24:59.999000Z" timestamp = "2024-06-03T14:00:00.000Z" result = subtract_epsilon_from_timestamp(timestamp) assert result == "2024-06-03T13:59:59.999000Z"