MondayEventCollector

Collects Monday.com audit logs and activity events for Cortex XSIAM using OAuth 2.0 authentication.

Analytics & SIEM · Monday

Details

IDMondayEventCollector
ProviderMonday.com
CategoryAnalytics & SIEM
From Version8.3.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesXSIAM

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

Activity log API docs

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&region=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

Audit log API docs

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

  1. 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&region=REGION&scope=boards%3Aread&state=
  2. Copy the AUTH_CODE (without the code= 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 URL
  • credentials — Client ID
  • auth_code
  • board_ids — Board IDs
  • eventFetchInterval — Events Fetch Interval
  • max_activity_logs_per_fetch — Maximum number of Activity Logs per board per fetch
  • audit_logs_url — Audit Server URL
  • audit_token
  • max_audit_logs_per_fetch — Maximum number of Audit Logs per fetch
  • isFetchEvents — Fetch events
  • selected_event_types — Event types
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (2)

  • monday-auth-test

    Run this command to test the connectivity to Monday.

  • monday-generate-login-url

    Generate 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"