From 59a0d4b06490f41b5729b4e0409b32b4789055f6 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 21 Feb 2026 20:26:26 +0000 Subject: [PATCH 01/13] feat: implement pagination helper for list_dashboards --- src/secops/chronicle/client.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index cdf63b21..82aa09bd 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -4156,20 +4156,30 @@ def list_dashboards( self, page_size: int | None = None, page_token: str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, + as_list: bool = False, ) -> dict[str, Any]: - """List all available dashboards. + """List all available dashboards in Basic View. Args: page_size: Maximum number of results to return page_token: Token for pagination + api_version: Preferred API version to use. Defaults to V1ALPHA + as_list: Whether to return results as a list or dictionary Returns: - Dictionary containing dashboard list and pagination info + If as_list is True: List of dashboards. + If as_list is False: Dictionary containing list of dashboards and pagination info. + + Raises: + APIError: If the API request fails """ return _list_dashboards( self, page_size=page_size, page_token=page_token, + api_version=api_version, + as_list=as_list, ) def get_dashboard( From b099398783e13331f91686fbae9d7a8e52359042 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 21 Feb 2026 20:26:29 +0000 Subject: [PATCH 02/13] feat: implement pagination helper for list_dashboards --- src/secops/chronicle/dashboard.py | 40 ++++++++++++++++++------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/secops/chronicle/dashboard.py b/src/secops/chronicle/dashboard.py index 4e12947e..e6260fed 100644 --- a/src/secops/chronicle/dashboard.py +++ b/src/secops/chronicle/dashboard.py @@ -28,6 +28,11 @@ TileType, ) from secops.exceptions import APIError, SecOpsError +from secops.chronicle.models import APIVersion +from secops.chronicle.utils.request_utils import ( + chronicle_request, + chronicle_paginated_request, +) if TYPE_CHECKING: from secops.chronicle.client import ChronicleClient @@ -209,6 +214,8 @@ def list_dashboards( client: "ChronicleClient", page_size: int | None = None, page_token: str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, + as_list: bool = False, ) -> dict[str, Any]: """List all available dashboards in Basic View. @@ -216,26 +223,25 @@ def list_dashboards( client: ChronicleClient instance page_size: Maximum number of results to return page_token: Token for pagination + api_version: Preferred API version to use. Defaults to V1ALPHA + as_list: Whether to return results as a list or dictionary Returns: - Dictionary containing dashboard list and pagination info - """ - url = f"{client.base_url}/{client.instance_id}/nativeDashboards" - params = {} - if page_size: - params["pageSize"] = page_size - if page_token: - params["pageToken"] = page_token - - response = client.session.get(url, params=params) + If as_list is True: List of dashboards. + If as_list is False: Dictionary containing list of dashboards and pagination info. - if response.status_code != 200: - raise APIError( - f"Failed to list dashboards: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + Raises: + APIError: If the API request fails + """ + return chronicle_paginated_request( + client, + api_version=api_version, + path="nativeDashboards", + items_key="nativeDashboards", + page_size=page_size, + page_token=page_token, + as_list=as_list, + ) def get_dashboard( From a69bc75cef4d57a1cd9544a57348996ed299f009 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 21 Feb 2026 21:24:14 +0000 Subject: [PATCH 03/13] feat: implement request helpers for dashboard functions --- src/secops/chronicle/client.py | 9 ++ src/secops/chronicle/dashboard.py | 158 ++++++++++++------------------ 2 files changed, 74 insertions(+), 93 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 82aa09bd..908f68d5 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -4089,6 +4089,7 @@ def create_dashboard( description: str | None = None, filters: list[dict[str, Any]] | str | None = None, charts: list[dict[str, Any]] | str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Create a new native dashboard. @@ -4100,6 +4101,7 @@ def create_dashboard( (JSON or JSON string) charts: List of charts to include in the dashboard (JSON or JSON string) + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the created dashboard details @@ -4119,6 +4121,7 @@ def create_dashboard( description=description, filters=filters, charts=charts, + api_version=api_version, ) def import_dashboard(self, dashboard: dict[str, Any]) -> dict[str, Any]: @@ -4186,6 +4189,7 @@ def get_dashboard( self, dashboard_id: str, view: str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Get information about a specific dashboard. @@ -4193,9 +4197,13 @@ def get_dashboard( dashboard_id: ID of the dashboard to retrieve view: Level of detail to include in the response Defaults to BASIC + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing dashboard details + + Raises: + APIError: If the API request fails """ if view: try: @@ -4207,6 +4215,7 @@ def get_dashboard( self, dashboard_id=dashboard_id, view=view, + api_version=api_version, ) def update_dashboard( diff --git a/src/secops/chronicle/dashboard.py b/src/secops/chronicle/dashboard.py index e6260fed..e062d4aa 100644 --- a/src/secops/chronicle/dashboard.py +++ b/src/secops/chronicle/dashboard.py @@ -33,6 +33,7 @@ chronicle_request, chronicle_paginated_request, ) +from secops.chronicle.utils.format_utils import format_resource_id, parse_json_list if TYPE_CHECKING: from secops.chronicle.client import ChronicleClient @@ -71,6 +72,7 @@ def create_dashboard( description: str | None = None, filters: list[dict[str, Any]] | str | None = None, charts: list[dict[str, Any]] | str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Create a new native dashboard. @@ -79,8 +81,9 @@ def create_dashboard( display_name: Name of the dashboard to create access_type: Access type for the dashboard (Public or Private) description: Description for the dashboard - filters: Dictionary of filters to apply to the dashboard + filters: List of filters to apply to the dashboard charts: List of charts to include in the dashboard + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the created dashboard details @@ -88,53 +91,42 @@ def create_dashboard( Raises: APIError: If the API request fails """ - url = f"{client.base_url}/{client.instance_id}/nativeDashboards" + if filters is not None: + filters = parse_json_list(filters, "filters") - if filters and isinstance(filters, str): - try: - filters = json.loads(filters) - if not isinstance(filters, list): - filters = [filters] - except ValueError as e: - raise APIError("Invalid filters JSON") from e + if charts is not None: + charts = parse_json_list(charts, "charts") - if charts and isinstance(charts, str): - try: - charts = json.loads(charts) - if not isinstance(charts, list): - charts = [charts] - except ValueError as e: - raise APIError("Invalid charts JSON") from e + definition: dict[str, Any] = {} + if filters is not None: + definition["filters"] = filters + if charts is not None: + definition["charts"] = charts payload = { "displayName": display_name, - "definition": {}, + "definition": definition, "access": access_type, "type": "CUSTOM", } - if description: + if description is not None: payload["description"] = description - if filters: - payload["definition"]["filters"] = filters - - if charts: - payload["definition"]["charts"] = charts - - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to create dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path="nativeDashboards", + api_version=api_version, + json=payload, + error_message="Failed to create dashboard", + ) def import_dashboard( - client: "ChronicleClient", dashboard: dict[str, Any] + client: "ChronicleClient", + dashboard: dict[str, Any], + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Import a native dashboard. @@ -148,8 +140,6 @@ def import_dashboard( Raises: APIError: If the API request fails """ - url = f"{client.base_url}/{client.instance_id}/nativeDashboards:import" - # Validate dashboard data keys valid_keys = ["dashboard", "dashboardCharts", "dashboardQueries"] dashboard_keys = set(dashboard.keys()) @@ -161,15 +151,14 @@ def import_dashboard( payload = {"source": {"dashboards": [dashboard]}} - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to import dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path="nativeDashboards:import", + api_version=APIVersion.V1ALPHA, + json=payload, + error_message="Failed to import dashboard", + ) def export_dashboard( @@ -248,6 +237,7 @@ def get_dashboard( client: "ChronicleClient", dashboard_id: str, view: DashboardView | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Get information about a specific dashboard. @@ -256,30 +246,27 @@ def get_dashboard( dashboard_id: ID of the dashboard to retrieve view: Level of detail to include in the response Defaults to BASIC + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing dashboard details - """ - if dashboard_id.startswith("projects/"): - dashboard_id = dashboard_id.split("projects/")[-1] + Raises: + APIError: If the API request fails + """ + dashboard_id = format_resource_id(dashboard_id) - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) view = view or DashboardView.BASIC params = {"view": view.value} - response = client.session.get(url, params=params) - - if response.status_code != 200: - raise APIError( - f"Failed to get dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="GET", + endpoint_path=f"nativeDashboards/{dashboard_id}", + api_version=api_version, + params=params, + error_message=f"Failed to get dashboard with ID {dashboard_id}", + ) # Updated update_dashboard function @@ -290,6 +277,7 @@ def update_dashboard( description: str | None = None, filters: list[dict[str, Any]] | str | None = None, charts: list[dict[str, Any]] | str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Update an existing dashboard. @@ -300,17 +288,12 @@ def update_dashboard( description: New description for the dashboard (optional) filters: New filters for the dashboard (optional) charts: New charts for the dashboard (optional) + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the updated dashboard details """ - if dashboard_id.startswith("projects/"): - dashboard_id = dashboard_id.split("projects/")[-1] - - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) + dashboard_id = format_resource_id(dashboard_id) payload = {"definition": {}} update_mask = [] @@ -349,20 +332,18 @@ def update_dashboard( params = {"updateMask": ",".join(update_mask)} - response = client.session.patch(url, json=payload, params=params) - - if response.status_code != 200: - raise APIError( - f"Failed to update dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="PATCH", + endpoint_path=f"nativeDashboards/{dashboard_id}", + api_version=api_version, + json=payload, + params=params, + error_message=f"Failed to update dashboard with ID {dashboard_id}", + ) -def delete_dashboard( - client: "ChronicleClient", dashboard_id: str -) -> dict[str, Any]: +def delete_dashboard(client: "ChronicleClient", dashboard_id: str) -> dict[str, Any]: """Delete a dashboard. Args: @@ -376,10 +357,7 @@ def delete_dashboard( if dashboard_id.startswith("projects/"): dashboard_id = dashboard_id.split("projects/")[-1] - url = ( - f"{client.base_url}/{client.instance_id}" - f"/nativeDashboards/{dashboard_id}" - ) + url = f"{client.base_url}/{client.instance_id}" f"/nativeDashboards/{dashboard_id}" response = client.session.delete(url) @@ -501,9 +479,7 @@ def add_chart( if interval and isinstance(interval, str): interval = json.loads(interval) except ValueError as e: - raise APIError( - f"Failed to parse JSON. Must be a valid JSON string: {e}" - ) from e + raise APIError(f"Failed to parse JSON. Must be a valid JSON string: {e}") from e payload = { "dashboardChart": { @@ -654,9 +630,7 @@ def edit_chart( if dashboard_query: if isinstance(dashboard_query, str): try: - dashboard_query = DashboardQuery.from_dict( - json.loads(dashboard_query) - ) + dashboard_query = DashboardQuery.from_dict(json.loads(dashboard_query)) except ValueError as e: raise SecOpsError("Invalid dashboard query JSON") from e if isinstance(dashboard_query, dict): @@ -672,9 +646,7 @@ def edit_chart( if dashboard_chart: if isinstance(dashboard_chart, str): try: - dashboard_chart = DashboardChart.from_dict( - json.loads(dashboard_chart) - ) + dashboard_chart = DashboardChart.from_dict(json.loads(dashboard_chart)) except ValueError as e: raise SecOpsError("Invalid dashboard chart JSON") from e if isinstance(dashboard_chart, dict): From 442691849a1b98e9a1d437e959d4b1b4a0224466 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 21 Feb 2026 21:24:27 +0000 Subject: [PATCH 04/13] feat: helpers for formatting --- src/secops/chronicle/utils/format_utils.py | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/secops/chronicle/utils/format_utils.py diff --git a/src/secops/chronicle/utils/format_utils.py b/src/secops/chronicle/utils/format_utils.py new file mode 100644 index 00000000..cf12d55b --- /dev/null +++ b/src/secops/chronicle/utils/format_utils.py @@ -0,0 +1,64 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Formatting helper functions for Chronicle.""" + +import json +from typing import Any + +from secops.exceptions import APIError + + +def format_resource_id(resource_id: str) -> str: + """Extracts the correct ID for a resource string when the full + resource name is provided. + + Example: + full resource string: + "projects/12345/locations/eu/instances/.../123-ID-abc" + extracted ID: "123-ID-abc" + + Args: + resource_id: The full resource string or just the ID. + + Returns: + The extracted ID from the resource string, or the original string if it doesn't match the expected format. + """ + if resource_id.startswith("projects/"): + return resource_id.split("projects/")[-1] + return resource_id + + +def parse_json_list( + value: list[dict[str, Any]] | str, field_name: str +) -> list[dict[str, Any]]: + """Parse a JSON string into a list, or return the list as-is. + + Args: + value: A list of dictionaries or a JSON string representing a list of dictionaries. + field_name: The name of the field being parsed, used for error messages. + + Returns: + A list of dictionaries parsed from the JSON string, or the original list if it was already a list. + + Raises: + APIError: If the input is a string but cannot be parsed as valid JSON. + """ + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else [parsed] + except ValueError as e: + raise APIError(f"Invalid {field_name} JSON") from e + return value From 36ebe6e131ad8d0ce1b5ba43e8adc400a9e5bd36 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sun, 22 Feb 2026 20:30:41 +0000 Subject: [PATCH 05/13] feat: testing for format_utils.py --- tests/chronicle/utils/test_format_utils.py | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/chronicle/utils/test_format_utils.py diff --git a/tests/chronicle/utils/test_format_utils.py b/tests/chronicle/utils/test_format_utils.py new file mode 100644 index 00000000..833759b0 --- /dev/null +++ b/tests/chronicle/utils/test_format_utils.py @@ -0,0 +1,94 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for format helper functions.""" +from __future__ import annotations + +import pytest + +from secops.chronicle.utils.format_utils import format_resource_id, parse_json_list +from secops.exceptions import APIError + + +def test_format_resource_id_returns_bare_id_unchanged() -> None: + # A plain ID with no path prefix should pass through as-is + assert format_resource_id("123-ID-abc") == "123-ID-abc" + + +def test_format_resource_id_extracts_id_from_full_resource_name() -> None: + # Full resource name should have everything up to and including "projects/" stripped + full_name = "projects/12345/locations/eu/instances/my-instance/nativeDashboards/123-ID-abc" + assert format_resource_id(full_name) == "12345/locations/eu/instances/my-instance/nativeDashboards/123-ID-abc" + + +def test_format_resource_id_handles_minimal_projects_prefix() -> None: + # Minimal case: just "projects/" + assert format_resource_id("projects/my-project") == "my-project" + + +def test_format_resource_id_does_not_alter_non_projects_paths() -> None: + # Paths that don't start with "projects/" should be returned as-is + assert format_resource_id("instances/my-instance/dashboards/abc") == "instances/my-instance/dashboards/abc" + + +def test_format_resource_id_empty_string_returns_empty_string() -> None: + assert format_resource_id("") == "" + + +def test_parse_json_list_returns_list_unchanged() -> None: + # A pre-built list should be returned as-is without any parsing + value = [{"key": "value"}, {"key2": "value2"}] + assert parse_json_list(value, "filters") is value + + +def test_parse_json_list_parses_valid_json_array_string() -> None: + json_str = '[{"key": "value"}, {"key2": "value2"}]' + result = parse_json_list(json_str, "filters") + assert result == [{"key": "value"}, {"key2": "value2"}] + + +def test_parse_json_list_wraps_single_json_object_in_list() -> None: + # A JSON string containing a single object (not an array) should be wrapped + json_str = '{"key": "value"}' + result = parse_json_list(json_str, "filters") + assert result == [{"key": "value"}] + + +def test_parse_json_list_raises_api_error_on_invalid_json() -> None: + with pytest.raises(APIError, match="Invalid filters JSON"): + parse_json_list("not valid json {", "filters") + + +def test_parse_json_list_error_message_includes_field_name() -> None: + # The field name should appear in the error to aid debugging + with pytest.raises(APIError, match="Invalid charts JSON"): + parse_json_list("{bad json", "charts") + + +def test_parse_json_list_raises_api_error_chained_from_value_error() -> None: + # The APIError should chain from the underlying ValueError + with pytest.raises(APIError) as exc_info: + parse_json_list("bad json", "filters") + assert exc_info.value.__cause__ is not None + assert isinstance(exc_info.value.__cause__, ValueError) + + +def test_parse_json_list_handles_empty_json_array() -> None: + result = parse_json_list("[]", "filters") + assert result == [] + + +def test_parse_json_list_handles_empty_list_input() -> None: + result = parse_json_list([], "filters") + assert result == [] \ No newline at end of file From 43ec2a175c4cf28b1d2fae5daad06d90dee341da Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sun, 22 Feb 2026 20:46:17 +0000 Subject: [PATCH 06/13] feat: fix logic for getting ID from resource --- src/secops/chronicle/utils/format_utils.py | 2 +- tests/chronicle/utils/test_format_utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/secops/chronicle/utils/format_utils.py b/src/secops/chronicle/utils/format_utils.py index cf12d55b..dd951d2c 100644 --- a/src/secops/chronicle/utils/format_utils.py +++ b/src/secops/chronicle/utils/format_utils.py @@ -36,7 +36,7 @@ def format_resource_id(resource_id: str) -> str: The extracted ID from the resource string, or the original string if it doesn't match the expected format. """ if resource_id.startswith("projects/"): - return resource_id.split("projects/")[-1] + return resource_id.split("/")[-1] return resource_id diff --git a/tests/chronicle/utils/test_format_utils.py b/tests/chronicle/utils/test_format_utils.py index 833759b0..932800d6 100644 --- a/tests/chronicle/utils/test_format_utils.py +++ b/tests/chronicle/utils/test_format_utils.py @@ -27,9 +27,9 @@ def test_format_resource_id_returns_bare_id_unchanged() -> None: def test_format_resource_id_extracts_id_from_full_resource_name() -> None: - # Full resource name should have everything up to and including "projects/" stripped + # Full resource name should have the ID extracted correctly full_name = "projects/12345/locations/eu/instances/my-instance/nativeDashboards/123-ID-abc" - assert format_resource_id(full_name) == "12345/locations/eu/instances/my-instance/nativeDashboards/123-ID-abc" + assert format_resource_id(full_name) == "123-ID-abc" def test_format_resource_id_handles_minimal_projects_prefix() -> None: From 935155856873559d32b6c84de92b8fdc488b0510 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 24 Feb 2026 22:15:08 +0000 Subject: [PATCH 07/13] feat: implement helper functions --- README.md | 8 +- src/secops/chronicle/client.py | 93 ++++++++-- src/secops/chronicle/dashboard.py | 286 +++++++++++++++--------------- 3 files changed, 226 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index 26951a1c..0cde31f3 100644 --- a/README.md +++ b/README.md @@ -2930,9 +2930,13 @@ dashboard = chronicle.get_dashboard( print(f"Dashboard Details: {dashboard}") ``` -### List Dashboards with pagination +### List Dashboards ```python -# List dashboards (first page) +dashboards = chronicle.list_dashboards() +for dashboard in dashboards.get("nativeDashboards", []): + print(f"- {dashboard.get('displayName')}") + +# List dashboards with pagination(first page) dashboards = chronicle.list_dashboards(page_size=10) for dashboard in dashboards.get("nativeDashboards", []): print(f"- {dashboard.get('displayName')}") diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 908f68d5..c83108d9 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -4124,11 +4124,16 @@ def create_dashboard( api_version=api_version, ) - def import_dashboard(self, dashboard: dict[str, Any]) -> dict[str, Any]: + def import_dashboard( + self, + dashboard: dict[str, Any], + api_version: APIVersion | None = APIVersion.V1ALPHA, + ) -> dict[str, Any]: """Create a new native dashboard. Args: dashboard: ImportNativeDashboardsInlineSource + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the created dashboard details @@ -4136,15 +4141,21 @@ def import_dashboard(self, dashboard: dict[str, Any]) -> dict[str, Any]: Raises: APIError: If the API request fails """ + return _import_dashboard( + self, dashboard=dashboard, api_version=api_version + ) - return _import_dashboard(self, dashboard=dashboard) - - def export_dashboard(self, dashboard_names: list[str]) -> dict[str, Any]: + def export_dashboard( + self, + dashboard_names: list[str], + api_version: APIVersion | None = APIVersion.V1ALPHA, + ) -> dict[str, Any]: """Export native dashboards. It supports single dashboard export operation only. Args: dashboard_names: List of dashboard resource names to export. + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the exported dashboards. @@ -4152,8 +4163,9 @@ def export_dashboard(self, dashboard_names: list[str]) -> dict[str, Any]: Raises: APIError: If the API request fails """ - - return _export_dashboard(self, dashboard_names=dashboard_names) + return _export_dashboard( + self, dashboard_names=dashboard_names, api_version=api_version + ) def list_dashboards( self, @@ -4225,6 +4237,7 @@ def update_dashboard( description: str | None = None, filters: list[dict[str, Any]] | str | None = None, charts: list[dict[str, Any]] | str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Update an existing dashboard. @@ -4234,6 +4247,7 @@ def update_dashboard( description: New description for the dashboard (optional) filters: New filters for the dashboard (optional) charts: New charts for the dashboard (optional) + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the updated dashboard details @@ -4245,15 +4259,29 @@ def update_dashboard( description=description, filters=filters, charts=charts, + api_version=api_version, ) - def delete_dashboard(self, dashboard_id: str) -> dict[str, Any]: + def delete_dashboard( + self, + dashboard_id: str, + api_version: APIVersion | None = APIVersion.V1ALPHA, + ) -> dict[str, Any]: """Delete an existing dashboard. Args: dashboard_id: ID of the dashboard to delete + api_version: Preferred API version to use. Defaults to V1ALPHA + + Returns: + Empty dictionary if deletion is successful + + Raises: + APIError: If the API request fails """ - return _delete_dashboard(self, dashboard_id=dashboard_id) + return _delete_dashboard( + self, dashboard_id=dashboard_id, api_version=api_version + ) def add_chart( self, @@ -4267,6 +4295,7 @@ def add_chart( description: str | None = None, query: str | None = None, interval: InputInterval | dict[str, Any] | str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, **kwargs, ) -> dict[str, Any]: """Add a chart to an existing dashboard. @@ -4285,6 +4314,7 @@ def add_chart( description: Description for the chart query: Query for the chart interval: Query input interval for the chart + api_version: Preferred API version to use. Defaults to V1ALPHA **kwargs: Additional keyword arguments (Will be added to request payload) @@ -4309,6 +4339,7 @@ def add_chart( description=description, query=query, interval=interval, + api_version=api_version, **kwargs, ) @@ -4318,17 +4349,24 @@ def duplicate_dashboard( display_name: str, access_type: str, description: str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Duplicate an existing dashboard. Args: - dashboard_id: Id of the dashboard to duplicate - display_name: Display name for the new dashboard - access_type: Access type for the new dashboard (PRIVATE or PUBLIC) - description: Description for the new dashboard + client: ChronicleClient instance + dashboard_id: ID of the dashboard to duplicate + display_name: New name for the duplicated dashboard + access_type: Access type for the duplicated dashboard + (DashboardAccessType.PRIVATE or DashboardAccessType.PUBLIC) + description: Description for the duplicated dashboard + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: - Dictionary containing the updated dashboard details + Dictionary containing the duplicated dashboard details + + Raises: + APIError: If the API request fails """ try: access_type = DashboardAccessType[access_type.upper()] @@ -4341,18 +4379,21 @@ def duplicate_dashboard( display_name=display_name, access_type=access_type, description=description, + api_version=api_version, ) def remove_chart( self, dashboard_id: str, chart_id: str, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Remove a chart from a dashboard. Args: dashboard_id: ID of the dashboard containing the chart chart_id: ID of the chart to remove + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the updated dashboard @@ -4364,24 +4405,34 @@ def remove_chart( self, dashboard_id=dashboard_id, chart_id=chart_id, + api_version=api_version ) - def get_chart(self, chart_id: str) -> dict[str, Any]: - """Get information about a specific chart. + def get_chart( + self, + chart_id: str, + api_version: APIVersion | None = APIVersion.V1ALPHA, + ) -> dict[str, Any]: + """Get detail for dashboard chart. Args: - chart_id: ID of the chart to retrieve + chart_id: ID of the chart + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: - Dictionary containing chart details + Dict[str, Any]: Dictionary containing chart details + + Raises: + APIError: If the API request fails """ - return _get_chart(self, chart_id) + return _get_chart(self, chart_id, api_version) def edit_chart( self, dashboard_id: str, dashboard_chart: None | (dict[str, Any] | DashboardChart | str) = None, dashboard_query: None | (dict[str, Any] | DashboardQuery | str) = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Edit an existing chart in a dashboard. @@ -4404,8 +4455,13 @@ def edit_chart( "input": {}, "etag":"123131231321321" } + api_version: Preferred API version to use. Defaults to V1ALPHA + Returns: Dictionary containing the updated dashboard with edited chart + + Raises: + APIError: If the API request fails """ return _edit_chart( @@ -4413,6 +4469,7 @@ def edit_chart( dashboard_id=dashboard_id, dashboard_chart=dashboard_chart, dashboard_query=dashboard_query, + api_version=api_version, ) def execute_dashboard_query( diff --git a/src/secops/chronicle/dashboard.py b/src/secops/chronicle/dashboard.py index e062d4aa..bbe45304 100644 --- a/src/secops/chronicle/dashboard.py +++ b/src/secops/chronicle/dashboard.py @@ -33,7 +33,10 @@ chronicle_request, chronicle_paginated_request, ) -from secops.chronicle.utils.format_utils import format_resource_id, parse_json_list +from secops.chronicle.utils.format_utils import ( + format_resource_id, + parse_json_list, +) if TYPE_CHECKING: from secops.chronicle.client import ChronicleClient @@ -65,6 +68,11 @@ class DashboardView(StrEnum): FULL = "NATIVE_DASHBOARD_VIEW_FULL" +_VALID_DASHBOARD_KEYS = frozenset( + {"dashboard", "dashboardCharts", "dashboardQueries"} +) + + def create_dashboard( client: "ChronicleClient", display_name: str, @@ -132,21 +140,20 @@ def import_dashboard( Args: client: ChronicleClient instance - dashboard: ImportNativeDashboardsInlineSource + dashboard: Dashboard data to import, must contain at least one of: + 'dashboard', 'dashboardCharts', or 'dashboardQueries' + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: - Dictionary containing the created dashboard details + Dictionary containing the imported dashboard details Raises: + SecOpsError: If the dashboard data contains none of the required keys APIError: If the API request fails """ - # Validate dashboard data keys - valid_keys = ["dashboard", "dashboardCharts", "dashboardQueries"] - dashboard_keys = set(dashboard.keys()) - - if not any(key in dashboard_keys for key in valid_keys): + if not any(key in dashboard for key in _VALID_DASHBOARD_KEYS): raise SecOpsError( - f'Dashboard must contain at least one of: {", ".join(valid_keys)}' + f'Dashboard must contain at least one of: {", ".join(_VALID_DASHBOARD_KEYS)}' ) payload = {"source": {"dashboards": [dashboard]}} @@ -155,14 +162,16 @@ def import_dashboard( client, method="POST", endpoint_path="nativeDashboards:import", - api_version=APIVersion.V1ALPHA, + api_version=api_version, json=payload, error_message="Failed to import dashboard", ) def export_dashboard( - client: "ChronicleClient", dashboard_names: list[str] + client: "ChronicleClient", + dashboard_names: list[str], + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Export native dashboards. It supports single dashboard export operation only. @@ -170,6 +179,7 @@ def export_dashboard( Args: client: ChronicleClient instance dashboard_names: List of dashboard resource names to export. + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the exported dashboards. @@ -177,8 +187,6 @@ def export_dashboard( Raises: APIError: If the API request fails. """ - url = f"{client.base_url}/{client.instance_id}/nativeDashboards:export" - # Ensure dashboard names are fully qualified qualified_names = [] for name in dashboard_names: @@ -188,15 +196,14 @@ def export_dashboard( payload = {"names": qualified_names} - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to export dashboards: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path="nativeDashboards:export", + api_version=api_version, + json=payload, + error_message="Failed to export dashboards", + ) def list_dashboards( @@ -292,27 +299,21 @@ def update_dashboard( Returns: Dictionary containing the updated dashboard details + + Raises: + ValueError: If no fields are provided to update + APIError: If filters or charts JSON is invalid, or if the API request fails """ dashboard_id = format_resource_id(dashboard_id) - payload = {"definition": {}} - update_mask = [] + if filters is not None: + filters = parse_json_list(filters, "filters") - if filters and isinstance(filters, str): - try: - filters = json.loads(filters) - if not isinstance(filters, list): - filters = [filters] - except ValueError as e: - raise APIError("Invalid filters JSON") from e - - if charts and isinstance(charts, str): - try: - charts = json.loads(charts) - if not isinstance(charts, list): - charts = [charts] - except ValueError as e: - raise APIError("Invalid charts JSON") from e + if charts is not None: + charts = parse_json_list(charts, "charts") + + payload = {} + update_mask = [] if display_name is not None: payload["displayName"] = display_name @@ -322,15 +323,20 @@ def update_dashboard( payload["description"] = description update_mask.append("description") + definition = {} if filters is not None: - payload["definition"]["filters"] = filters + definition["filters"] = filters update_mask.append("definition.filters") if charts is not None: - payload["definition"]["charts"] = charts + definition["charts"] = charts update_mask.append("definition.charts") - params = {"updateMask": ",".join(update_mask)} + if definition: + payload["definition"] = definition + + if not update_mask: + raise ValueError("At least one field must be provided to update") return chronicle_request( client, @@ -338,36 +344,38 @@ def update_dashboard( endpoint_path=f"nativeDashboards/{dashboard_id}", api_version=api_version, json=payload, - params=params, + params={"updateMask": ",".join(update_mask)}, error_message=f"Failed to update dashboard with ID {dashboard_id}", ) -def delete_dashboard(client: "ChronicleClient", dashboard_id: str) -> dict[str, Any]: +def delete_dashboard( + client: "ChronicleClient", + dashboard_id: str, + api_version: APIVersion | None = APIVersion.V1ALPHA, +) -> dict[str, Any]: """Delete a dashboard. Args: client: ChronicleClient instance dashboard_id: ID of the dashboard to delete + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Empty dictionary on success - """ - - if dashboard_id.startswith("projects/"): - dashboard_id = dashboard_id.split("projects/")[-1] - - url = f"{client.base_url}/{client.instance_id}" f"/nativeDashboards/{dashboard_id}" - response = client.session.delete(url) - - if response.status_code != 200: - raise APIError( - f"Failed to delete dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) + Raises: + APIError: If the API request fails + """ + dashboard_id = format_resource_id(dashboard_id) - return {"status": "success", "code": response.status_code} + return chronicle_request( + client, + method="DELETE", + endpoint_path=f"nativeDashboards/{dashboard_id}", + api_version=api_version, + error_message=f"Failed to delete dashboard with ID {dashboard_id}", + ) def duplicate_dashboard( @@ -376,6 +384,7 @@ def duplicate_dashboard( display_name: str, access_type: DashboardAccessType, description: str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Duplicate a existing dashboard. @@ -386,17 +395,15 @@ def duplicate_dashboard( access_type: Access type for the duplicated dashboard (DashboardAccessType.PRIVATE or DashboardAccessType.PUBLIC) description: Description for the duplicated dashboard + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the duplicated dashboard details - """ - if dashboard_id.startswith("projects/"): - dashboard_id = dashboard_id.split("projects/")[-1] - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}:duplicate" - ) + Raises: + APIError: If the API request fails + """ + dashboard_id = format_resource_id(dashboard_id) payload = { "nativeDashboard": { @@ -409,15 +416,14 @@ def duplicate_dashboard( if description: payload["nativeDashboard"]["description"] = description - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to duplicate dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path=f"nativeDashboards/{dashboard_id}:duplicate", + api_version=api_version, + json=payload, + error_message=f"Failed to duplicate dashboard with ID {dashboard_id}", + ) def add_chart( @@ -432,6 +438,7 @@ def add_chart( description: str | None = None, query: str | None = None, interval: InputInterval | dict[str, Any] | str | None = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, **kwargs, ) -> dict[str, Any]: """Add a chart to a dashboard. @@ -449,20 +456,17 @@ def add_chart( description: The description for the chart query: The search query for chart interval: The time interval for the query + api_version: Preferred API version to use. Defaults to V1ALPHA **kwargs: Additional keyword arguments (It will be added to the request payload) - Returns: Dictionary containing the updated dashboard with new chart - """ - if dashboard_id.startswith("projects/"): - dashboard_id = dashboard_id.split("projects/")[-1] - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}:addChart" - ) + Raises: + APIError: If the API request fails or if JSON parsing fails + """ + dashboard_id = format_resource_id(dashboard_id) tile_type = TileType.VISUALIZATION if tile_type is None else tile_type @@ -479,7 +483,9 @@ def add_chart( if interval and isinstance(interval, str): interval = json.loads(interval) except ValueError as e: - raise APIError(f"Failed to parse JSON. Must be a valid JSON string: {e}") from e + raise APIError( + f"Failed to parse JSON. Must be a valid JSON string: {e}" + ) from e payload = { "dashboardChart": { @@ -514,45 +520,49 @@ def add_chart( } ) - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to add chart: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path=f"nativeDashboards/{dashboard_id}:addChart", + api_version=api_version, + json=payload, + error_message=f"Failed to add chart to dashboard with ID {dashboard_id}", + ) -def get_chart(client: "ChronicleClient", chart_id: str) -> dict[str, Any]: +def get_chart( + client: "ChronicleClient", + chart_id: str, + api_version: APIVersion | None = APIVersion.V1ALPHA, +) -> dict[str, Any]: """Get detail for dashboard chart. Args: client: ChronicleClient instance chart_id: ID of the chart + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dict[str, Any]: Dictionary containing chart details - """ - if chart_id.startswith("projects/"): - chart_id = chart_id.split("/")[-1] - url = f"{client.base_url}/{client.instance_id}/dashboardCharts/{chart_id}" - response = client.session.get(url) - - if response.status_code != 200: - raise APIError( - f"Failed to get chart details: Status {response.status_code}, " - f"Response: {response.text}" - ) - return response.json() + Raises: + APIError: If the API request fails + """ + chart_id = format_resource_id(chart_id) + return chronicle_request( + client, + method="GET", + endpoint_path=f"dashboardCharts/{chart_id}", + api_version=api_version, + error_message=f"Failed to get chart with ID {chart_id}", + ) def remove_chart( client: "ChronicleClient", dashboard_id: str, chart_id: str, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Remove a chart from a dashboard. @@ -560,6 +570,7 @@ def remove_chart( client: ChronicleClient instance dashboard_id: ID of the dashboard containing the chart chart_id: ID of the chart to remove + api_version: Preferred API version to use. Defaults to V1ALPHA Returns: Dictionary containing the updated dashboard @@ -567,35 +578,25 @@ def remove_chart( Raises: APIError: If the API request fails """ - if dashboard_id.startswith("projects/"): - dashboard_id = dashboard_id.split("projects/")[-1] - - if not chart_id.startswith("projects/"): - chart_id = f"{client.instance_id}/dashboardCharts/{chart_id}" + dashboard_id = format_resource_id(dashboard_id) + chart_id = format_resource_id(chart_id) - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}:removeChart" + return chronicle_request( + client, + method="POST", + endpoint_path=f"nativeDashboards/{dashboard_id}:removeChart", + api_version=api_version, + json={"dashboardChart": chart_id}, + error_message=f"Failed to remove chart with ID {chart_id} from dashboard with ID {dashboard_id}", ) - payload = {"dashboardChart": chart_id} - - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to remove chart: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() - def edit_chart( client: "ChronicleClient", dashboard_id: str, dashboard_query: None | (dict[str, Any] | DashboardQuery | str) = None, dashboard_chart: None | (dict[str, Any] | DashboardChart | str) = None, + api_version: APIVersion | None = APIVersion.V1ALPHA, ) -> dict[str, Any]: """Edit an existing chart in a dashboard. @@ -618,11 +619,15 @@ def edit_chart( "chartDatasource": { "dataSources":[]}, "etag": "123131231321321" } + api_version: Preferred API version to use. Defaults to V1ALPHA + Returns: Dictionary containing the updated dashboard with edited chart + + Raises: + APIError: If the API request fails or if JSON parsing fails """ - if dashboard_id.startswith("projects/"): - dashboard_id = dashboard_id.split("projects/")[-1] + dashboard_id = format_resource_id(dashboard_id) payload = {} update_fields = [] @@ -630,7 +635,9 @@ def edit_chart( if dashboard_query: if isinstance(dashboard_query, str): try: - dashboard_query = DashboardQuery.from_dict(json.loads(dashboard_query)) + dashboard_query = DashboardQuery.from_dict( + json.loads(dashboard_query) + ) except ValueError as e: raise SecOpsError("Invalid dashboard query JSON") from e if isinstance(dashboard_query, dict): @@ -646,7 +653,9 @@ def edit_chart( if dashboard_chart: if isinstance(dashboard_chart, str): try: - dashboard_chart = DashboardChart.from_dict(json.loads(dashboard_chart)) + dashboard_chart = DashboardChart.from_dict( + json.loads(dashboard_chart) + ) except ValueError as e: raise SecOpsError("Invalid dashboard chart JSON") from e if isinstance(dashboard_chart, dict): @@ -661,16 +670,11 @@ def edit_chart( payload["editMask"] = ",".join(update_fields) - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" + return chronicle_request( + client, + method="POST", + endpoint_path=f"nativeDashboards/{dashboard_id}:editChart", + api_version=api_version, + json=payload, + error_message=f"Failed to edit chart in dashboard with ID {dashboard_id}", ) - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to edit chart: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() From 113cda7f49ca35152bb3f7126770b27f9e4d87d3 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 24 Feb 2026 22:15:27 +0000 Subject: [PATCH 08/13] feat: rewritten tests to account for module changes --- tests/chronicle/test_dashboard.py | 1485 +++++++++++------------------ 1 file changed, 566 insertions(+), 919 deletions(-) diff --git a/tests/chronicle/test_dashboard.py b/tests/chronicle/test_dashboard.py index 720e81b3..5b3c9a0e 100644 --- a/tests/chronicle/test_dashboard.py +++ b/tests/chronicle/test_dashboard.py @@ -27,22 +27,13 @@ @pytest.fixture -def chronicle_client() -> ChronicleClient: - """Create a mock Chronicle client for testing. - - Returns: - A mock ChronicleClient instance. - """ - with patch("secops.auth.SecOpsAuth") as mock_auth: - mock_session = Mock() - mock_session.headers = {} - mock_auth.return_value.session = mock_session - client = ChronicleClient( - customer_id="test-customer", project_id="test-project" - ) - client.base_url = "https://testapi.com" - client.instance_id = "test-project/locations/test-location" - return client +def chronicle_client() -> Mock: + """Minimal client shape expected by chronicle_request() helper.""" + client = Mock() + client.instance_id = "projects/test-project/locations/test-location/instances/test-customer" + client.base_url = Mock(return_value="https://testapi.com") + client.session = Mock() + return client @pytest.fixture @@ -76,638 +67,515 @@ class TestGetDashboard: """Test the get_dashboard function.""" def test_get_dashboard_success( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test get_dashboard function with successful response.""" - chronicle_client.session.get.return_value = response_mock - dashboard_id = "test-dashboard" - - result = dashboard.get_dashboard(chronicle_client, dashboard_id) - - chronicle_client.session.get.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"view": "NATIVE_DASHBOARD_VIEW_BASIC"} - chronicle_client.session.get.assert_called_with(url, params=params) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.get_dashboard(chronicle_client, "test-dashboard") assert result == {"name": "test-dashboard"} - - def test_get_dashboard_with_view( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: - """Test get_dashboard function with view parameter.""" - chronicle_client.session.get.return_value = response_mock - dashboard_id = "test-dashboard" - - result = dashboard.get_dashboard( - chronicle_client, dashboard_id, view=DashboardView.FULL + mock_req.assert_called_once_with( + chronicle_client, + method="GET", + endpoint_path="nativeDashboards/test-dashboard", + api_version=dashboard.APIVersion.V1ALPHA, + params={"view": DashboardView.BASIC}, + error_message="Failed to get dashboard with ID test-dashboard", ) - chronicle_client.session.get.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"view": "NATIVE_DASHBOARD_VIEW_FULL"} - chronicle_client.session.get.assert_called_with(url, params=params) + def test_get_dashboard_with_view(self, chronicle_client: Mock) -> None: + """Test get_dashboard function with view parameter.""" + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.get_dashboard( + chronicle_client, "test-dashboard", view=DashboardView.FULL + ) assert result == {"name": "test-dashboard"} + mock_req.assert_called_once() + assert mock_req.call_args.kwargs["params"] == {"view": DashboardView.FULL} - def test_get_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: - """Test get_dashboard function with error response.""" - response_mock.status_code = 404 - response_mock.text = "Dashboard not found" - chronicle_client.session.get.return_value = response_mock - dashboard_id = "nonexistent-dashboard" - - with pytest.raises(APIError, match="Failed to get dashboard"): - dashboard.get_dashboard(chronicle_client, dashboard_id) + def test_get_dashboard_error(self, chronicle_client: Mock) -> None: + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to get dashboard with ID nonexistent-dashboard"), + ): + with pytest.raises(APIError, match="Failed to get dashboard"): + dashboard.get_dashboard(chronicle_client, "nonexistent-dashboard") class TestUpdateDashboard: """Test the update_dashboard function.""" def test_update_dashboard_display_name( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test update_dashboard with display_name parameter.""" - chronicle_client.session.patch.return_value = response_mock - dashboard_id = "test-dashboard" - display_name = "Updated Dashboard" - - result = dashboard.update_dashboard( - chronicle_client, dashboard_id, display_name=display_name - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "display_name"} - payload = {"displayName": display_name, "definition": {}} - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.update_dashboard( + chronicle_client, "test-dashboard", display_name="Updated Dashboard" + ) assert result == {"name": "test-dashboard"} + mock_req.assert_called_once() + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "PATCH" + assert kwargs["endpoint_path"] == "nativeDashboards/test-dashboard" + assert kwargs["params"] == {"updateMask": "display_name"} + assert kwargs["json"] == {"displayName": "Updated Dashboard"} + assert "Failed to update dashboard" in kwargs["error_message"] def test_update_dashboard_description( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test update_dashboard with description parameter.""" - chronicle_client.session.patch.return_value = response_mock - dashboard_id = "test-dashboard" - description = "Updated description" - - result = dashboard.update_dashboard( - chronicle_client, dashboard_id, description=description - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "description"} - payload = {"description": description, "definition": {}} - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.update_dashboard( + chronicle_client, "test-dashboard", description="Updated description" + ) assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["params"] == {"updateMask": "description"} + assert kwargs["json"] == {"description": "Updated description"} def test_update_dashboard_filters( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test update_dashboard with filters parameter.""" - chronicle_client.session.patch.return_value = response_mock - dashboard_id = "test-dashboard" filters = [{"field": "event_type", "value": "PROCESS_LAUNCH"}] - - result = dashboard.update_dashboard( - chronicle_client, dashboard_id, filters=filters - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "definition.filters"} - payload = {"definition": {"filters": filters}} - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.update_dashboard(chronicle_client, "test-dashboard", filters=filters) assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["params"] == {"updateMask": "definition.filters"} + assert kwargs["json"] == {"definition": {"filters": filters}} def test_update_dashboard_charts( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test update_dashboard with charts parameter.""" - chronicle_client.session.patch.return_value = response_mock - dashboard_id = "test-dashboard" charts = [{"chart_id": "chart-1", "position": {"row": 0, "col": 0}}] - - result = dashboard.update_dashboard( - chronicle_client, dashboard_id, charts=charts - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "definition.charts"} - payload = {"definition": {"charts": charts}} - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.update_dashboard(chronicle_client, "test-dashboard", charts=charts) assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["params"] == {"updateMask": "definition.charts"} + assert kwargs["json"] == {"definition": {"charts": charts}} def test_update_dashboard_multiple_fields( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test update_dashboard with multiple parameters.""" - chronicle_client.session.patch.return_value = response_mock - dashboard_id = "test-dashboard" - display_name = "Updated Dashboard" - description = "Updated description" - - result = dashboard.update_dashboard( - chronicle_client, - dashboard_id, - display_name=display_name, - description=description, - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "display_name,description"} - payload = { - "displayName": display_name, - "description": description, - "definition": {}, - } - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.update_dashboard( + chronicle_client, + "test-dashboard", + display_name="Updated Dashboard", + description="Updated description", + ) assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + # order matters because implementation appends in a fixed order + assert kwargs["params"] == {"updateMask": "display_name,description"} + assert kwargs["json"] == { + "displayName": "Updated Dashboard", + "description": "Updated description", + } def test_update_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test update_dashboard function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.patch.return_value = response_mock - dashboard_id = "test-dashboard" - - with pytest.raises(APIError, match="Failed to update dashboard"): - dashboard.update_dashboard( - chronicle_client, dashboard_id, display_name="Test" - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to update dashboard with ID test-dashboard"), + ): + with pytest.raises(APIError, match="Failed to update dashboard"): + dashboard.update_dashboard(chronicle_client, "test-dashboard", display_name="Test") class TestDeleteDashboard: """Test the delete_dashboard function.""" def test_delete_dashboard_success( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test delete_dashboard function with successful response.""" - response_mock.json.return_value = {"status": "success", "code": 200} - chronicle_client.session.delete.return_value = response_mock - dashboard_id = "test-dashboard" - - result = dashboard.delete_dashboard(chronicle_client, dashboard_id) - - chronicle_client.session.delete.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - chronicle_client.session.delete.assert_called_with(url) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"status": "success", "code": 200}, + ) as mock_req: + result = dashboard.delete_dashboard(chronicle_client, "test-dashboard") assert result == {"status": "success", "code": 200} + mock_req.assert_called_once_with( + chronicle_client, + method="DELETE", + endpoint_path="nativeDashboards/test-dashboard", + api_version=dashboard.APIVersion.V1ALPHA, + error_message="Failed to delete dashboard with ID test-dashboard", + ) def test_delete_dashboard_with_project_id( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test delete_dashboard with project ID in dashboard_id.""" - response_mock.json.return_value = {"status": "success", "code": 200} - chronicle_client.session.delete.return_value = response_mock - dashboard_id = ( - "projects/test-project/locations/test-location" - "/nativeDashboards/test-dashboard" - ) + full_id = "projects/test-project/locations/test-location/nativeDashboards/test-dashboard" + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"status": "success", "code": 200}, + ) as mock_req: + result = dashboard.delete_dashboard(chronicle_client, full_id) - result = dashboard.delete_dashboard(chronicle_client, dashboard_id) - - chronicle_client.session.delete.assert_called_once() - expected_id = ( - "test-project/locations/test-location/nativeDashboards/" - "test-dashboard" - ) - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{expected_id}" - ) - chronicle_client.session.delete.assert_called_with(url) - - assert result == {"status": "success", "code": 200} + assert result["status"] == "success" + assert mock_req.call_args.kwargs["endpoint_path"] == "nativeDashboards/test-dashboard" def test_delete_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test delete_dashboard function with error response.""" - response_mock.status_code = 404 - response_mock.text = "Dashboard not found" - chronicle_client.session.delete.return_value = response_mock - dashboard_id = "nonexistent-dashboard" - - with pytest.raises(APIError, match="Failed to delete dashboard"): - dashboard.delete_dashboard(chronicle_client, dashboard_id) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to delete dashboard with ID nonexistent-dashboard"), + ): + with pytest.raises(APIError, match="Failed to delete dashboard"): + dashboard.delete_dashboard(chronicle_client, "nonexistent-dashboard") class TestRemoveChart: """Test the remove_chart function.""" def test_remove_chart_success( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test remove_chart function with successful response.""" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - chart_id = "test-chart" - - result = dashboard.remove_chart( - chronicle_client, dashboard_id, chart_id - ) - - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:removeChart" - ) - payload = { - "dashboardChart": "test-project/locations/test-location/" - "dashboardCharts/test-chart" - } - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.remove_chart(chronicle_client, "test-dashboard", "test-chart") assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards/test-dashboard:removeChart" + assert "Failed to remove chart" in kwargs["error_message"] def test_remove_chart_with_full_ids( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test remove_chart with full project IDs.""" - chronicle_client.session.post.return_value = response_mock - dashboard_id = ( - "projects/test-project/locations/test-location/" - "nativeDashboards/test-dashboard" - ) - chart_id = ( - "projects/test-project/locations/test-location/" - "dashboardCharts/test-chart" - ) - - result = dashboard.remove_chart( - chronicle_client, dashboard_id, chart_id - ) + dashboard_id = "projects/test-project/locations/test-location/nativeDashboards/test-dashboard" + chart_id = "projects/test-project/locations/test-location/dashboardCharts/test-chart" - chronicle_client.session.post.assert_called_once() - expected_id = ( - "test-project/locations/test-location/nativeDashboards/" - "test-dashboard" - ) - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{expected_id}:removeChart" - ) - payload = {"dashboardChart": chart_id} - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.remove_chart(chronicle_client, dashboard_id, chart_id) assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["endpoint_path"] == "nativeDashboards/test-dashboard:removeChart" def test_remove_chart_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test remove_chart function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - chart_id = "test-chart" - - with pytest.raises(APIError, match="Failed to remove chart"): - dashboard.remove_chart(chronicle_client, dashboard_id, chart_id) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to remove chart"), + ): + with pytest.raises(APIError, match="Failed to remove chart"): + dashboard.remove_chart(chronicle_client, "test-dashboard", "test-chart") class TestListDashboards: """Test the list_dashboards function.""" def test_list_dashboards_success( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test list_dashboards function with successful response.""" - response_mock.json.return_value = { - "nativeDashboards": [ - {"name": "test-dashboard-1"}, - {"name": "test-dashboard-2"}, - ] + upstream = { + "nativeDashboards": [{"name": "test-dashboard-1"}, {"name": "test-dashboard-2"}] } - chronicle_client.session.get.return_value = response_mock - - result = dashboard.list_dashboards(chronicle_client) - - chronicle_client.session.get.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards" - chronicle_client.session.get.assert_called_with(url, params={}) - - assert len(result["nativeDashboards"]) == 2 - assert result["nativeDashboards"][0]["name"] == "test-dashboard-1" - assert result["nativeDashboards"][1]["name"] == "test-dashboard-2" + with patch( + "secops.chronicle.dashboard.chronicle_paginated_request", + return_value=upstream, + ) as mock_paged: + result = dashboard.list_dashboards(chronicle_client) + + assert result == upstream + mock_paged.assert_called_once_with( + chronicle_client, + api_version=dashboard.APIVersion.V1ALPHA, + path="nativeDashboards", + items_key="nativeDashboards", + page_size=None, + page_token=None, + as_list=False, + ) def test_list_dashboards_with_pagination( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test list_dashboards function with pagination parameters.""" - # Mock the API response with pagination data - response_mock.json.return_value = { - "nativeDashboards": [ - {"name": "test-dashboard-1"}, - {"name": "test-dashboard-2"}, - ], + upstream = { + "nativeDashboards": [{"name": "test-dashboard-1"}, {"name": "test-dashboard-2"}], "nextPageToken": "next-page-token", } - chronicle_client.session.get.return_value = response_mock + with patch( + "secops.chronicle.dashboard.chronicle_paginated_request", + return_value=upstream, + ) as mock_paged: + result = dashboard.list_dashboards(chronicle_client, page_size=10, page_token="t1") - # Call the function with pagination parameters - page_size = 10 - page_token = "current-page-token" - result = dashboard.list_dashboards( - chronicle_client, page_size=page_size, page_token=page_token - ) - - # Verify API call was made with correct parameters - chronicle_client.session.get.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards" - chronicle_client.session.get.assert_called_with( - url, params={"pageSize": page_size, "pageToken": page_token} - ) - - # Verify the returned data - assert len(result["nativeDashboards"]) == 2 - assert result["nativeDashboards"][0]["name"] == "test-dashboard-1" - assert result["nativeDashboards"][1]["name"] == "test-dashboard-2" - assert result["nextPageToken"] == "next-page-token" + assert result == upstream + kwargs = mock_paged.call_args.kwargs + assert kwargs["page_size"] == 10 + assert kwargs["page_token"] == "t1" def test_list_dashboards_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test list_dashboards function with error response.""" - response_mock.status_code = 500 - response_mock.text = "Internal Server Error" - chronicle_client.session.get.return_value = response_mock + with patch( + "secops.chronicle.dashboard.chronicle_paginated_request", + side_effect=APIError("Failed to list dashboards"), + ): + with pytest.raises(APIError, match="Failed to list dashboards"): + dashboard.list_dashboards(chronicle_client) + + def test_list_dashboards_as_list( + self, chronicle_client: Mock + ) -> None: + """Test list_dashboards function with as_list=True.""" + upstream = [{"name": "test-dashboard-1"}, {"name": "test-dashboard-2"}] + with patch( + "secops.chronicle.dashboard.chronicle_paginated_request", + return_value=upstream, + ) as mock_paged: + result = dashboard.list_dashboards(chronicle_client, as_list=True) + + assert result == [{"name": "test-dashboard-1"}, {"name": "test-dashboard-2"}] + kwargs = mock_paged.call_args.kwargs + assert kwargs["as_list"] is True + + def test_dashboard_as_list_missing_events( + self, chronicle_client: Mock + ) -> None: + """Test that as_list=True returns empty list when events missing.""" + with patch( + "secops.chronicle.dashboard.chronicle_paginated_request", + return_value=[], + ) as mock_paged: + result = dashboard.list_dashboards(chronicle_client, as_list=True) + + assert result == [] + assert len(result) == 0 + assert isinstance(result, list) + kwargs = mock_paged.call_args.kwargs + assert kwargs["as_list"] is True - with pytest.raises(APIError, match="Failed to list dashboards"): - dashboard.list_dashboards(chronicle_client) class TestCreateDashboard: """Test the create_dashboard function.""" def test_create_dashboard_minimal( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test create_dashboard with minimal required parameters.""" - chronicle_client.session.post.return_value = response_mock - display_name = "Test Dashboard" - access_type = dashboard.DashboardAccessType.PRIVATE - - result = dashboard.create_dashboard( - chronicle_client, display_name=display_name, access_type=access_type - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.create_dashboard( + chronicle_client, + display_name="Test Dashboard", + access_type=dashboard.DashboardAccessType.PRIVATE, + ) - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards" - payload = { - "displayName": display_name, + assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards" + assert kwargs["json"] == { + "displayName": "Test Dashboard", "access": "DASHBOARD_PRIVATE", "type": "CUSTOM", "definition": {}, } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} def test_create_dashboard_full( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test create_dashboard with all parameters.""" - chronicle_client.session.post.return_value = response_mock - display_name = "Test Dashboard" - access_type = dashboard.DashboardAccessType.PUBLIC - description = "Test description" filters = [{"field": "event_type", "value": "PROCESS_LAUNCH"}] charts = [{"chart_id": "chart-1", "position": {"row": 0, "col": 0}}] - result = dashboard.create_dashboard( - chronicle_client, - display_name=display_name, - access_type=access_type, - description=description, - filters=filters, - charts=charts, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.create_dashboard( + chronicle_client, + display_name="Test Dashboard", + access_type=dashboard.DashboardAccessType.PUBLIC, + description="Test description", + filters=filters, + charts=charts, + ) - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards" - payload = { - "displayName": display_name, + assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["json"] == { + "displayName": "Test Dashboard", "access": "DASHBOARD_PUBLIC", "type": "CUSTOM", - "description": description, + "description": "Test description", "definition": {"filters": filters, "charts": charts}, } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} def test_create_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test create_dashboard function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.post.return_value = response_mock - display_name = "Test Dashboard" - access_type = dashboard.DashboardAccessType.PRIVATE - - with pytest.raises(APIError, match="Failed to create dashboard"): - dashboard.create_dashboard( - chronicle_client, - display_name=display_name, - access_type=access_type, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to create dashboard"), + ): + with pytest.raises(APIError, match="Failed to create dashboard"): + dashboard.create_dashboard( + chronicle_client, + display_name="Test Dashboard", + access_type=dashboard.DashboardAccessType.PRIVATE, + ) class TestImportDashboard: """Test the import_dashboard function.""" def test_import_dashboard_success( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test import_dashboard function with successful response.""" - # Setup mock response - response_mock.json.return_value = { - "name": "projects/test-project/locations/test-location/nativeDashboards/imported-dashboard", - "displayName": "Imported Dashboard", - } - chronicle_client.session.post.return_value = response_mock - - # Dashboard to import dashboard_data = { "dashboard": { - "name": ( - "projects/test-project/locations/test-location/" - "nativeDashboards/dashboard-to-import" - ), + "name": "projects/test-project/locations/test-location/nativeDashboards/dashboard-to-import", "displayName": "test-dashboard", }, "dashboardCharts": [{"displayName": "Test Chart"}], "dashboardQueries": [ { "query": "sample_query", - "input": { - "relativeTime": { - "timeUnit": "SECOND", - "startTimeVal": "20", - } - }, + "input": {"relativeTime": {"timeUnit": "SECOND", "startTimeVal": "20"}}, } ], } - # Call the function - result = dashboard.import_dashboard(chronicle_client, dashboard_data) - - # Verify API call was made with correct parameters - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards:import" - payload = {"source": {"dashboards": [dashboard_data]}} - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={ + "name": "projects/test-project/locations/test-location/nativeDashboards/imported-dashboard", + "displayName": "Imported Dashboard", + }, + ) as mock_req: + result = dashboard.import_dashboard(chronicle_client, dashboard_data) - # Verify the returned result - assert result["name"].endswith("/imported-dashboard") assert result["displayName"] == "Imported Dashboard" + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards:import" + assert kwargs["json"] == {"source": {"dashboards": [dashboard_data]}} def test_import_dashboard_minimal( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test import_dashboard function with minimal dashboard data.""" - # Setup mock response - response_mock.json.return_value = {"name": "test-dashboard"} - chronicle_client.session.post.return_value = response_mock - - # Minimal dashboard to import dashboard_data = { "dashboard": { - "name": ( - "projects/test-project/locations/test-location/" - "nativeDashboards/dashboard-to-import" - ), + "name": "projects/test-project/locations/test-location/nativeDashboards/dashboard-to-import", "displayName": "test-dashboard", }, "dashboardCharts": [], "dashboardQueries": [], } - # Call the function - result = dashboard.import_dashboard(chronicle_client, dashboard_data) - - # Verify API call was made with correct parameters - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards:import" - payload = {"source": {"dashboards": [dashboard_data]}} - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.import_dashboard(chronicle_client, dashboard_data) - # Verify the returned result assert result == {"name": "test-dashboard"} + assert mock_req.call_args.kwargs["json"] == {"source": {"dashboards": [dashboard_data]}} def test_import_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test import_dashboard function with server error response.""" - # Setup server error response - response_mock.status_code = 500 - response_mock.text = "Internal Server Error" - chronicle_client.session.post.return_value = response_mock - - # Valid dashboard data dashboard_data = { "dashboard": { - "name": ( - "projects/test-project/locations/test-location/" - "nativeDashboards/dashboard-to-import" - ), + "name": "projects/test-project/locations/test-location/nativeDashboards/dashboard-to-import", "displayName": "test-dashboard", }, "dashboardCharts": [{"displayName": "Test Chart"}], "dashboardQueries": [ - { - "query": "sample_query", - "input": { - "relativeTime": { - "timeUnit": "SECOND", - "startTimeVal": "20", - } - }, - } - ], + {"query": "sample_query", "input": {"relativeTime": {"timeUnit": "SECOND", "startTimeVal": "20"}}}], } - # Verify the function raises an APIError - with pytest.raises(APIError, match="Failed to import dashboard"): - dashboard.import_dashboard(chronicle_client, dashboard_data) - - # Verify API call was attempted - chronicle_client.session.post.assert_called_once() + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to import dashboard"), + ): + with pytest.raises(APIError, match="Failed to import dashboard"): + dashboard.import_dashboard(chronicle_client, dashboard_data) def test_import_dashboard_invalid_data( - self, chronicle_client: ChronicleClient + self, chronicle_client: Mock ) -> None: """Test import_dashboard function with invalid dashboard data.""" - # Dashboard data without any of the required keys invalid_dashboard_data = { "displayName": "Invalid Dashboard", "access": "DASHBOARD_PUBLIC", "type": "CUSTOM", } - # Verify the function raises a SecOpsError with the correct message - with pytest.raises( - SecOpsError, - match=( - "Dashboard must contain " - "at least one of: dashboard, dashboardCharts, dashboardQueries" - ), - ): + with pytest.raises(SecOpsError) as exc: dashboard.import_dashboard(chronicle_client, invalid_dashboard_data) - # Verify no API call was attempted - chronicle_client.session.post.assert_not_called() + # Avoid depending on set ordering in the error message. + msg = str(exc.value) + assert "Dashboard must contain at least one of" in msg + assert "dashboard" in msg + assert "dashboardCharts" in msg + assert "dashboardQueries" in msg class TestAddChart: @@ -715,432 +583,258 @@ class TestAddChart: @pytest.fixture def chart_layout(self) -> Dict[str, Any]: - """Create a sample chart layout for testing. - - Returns: - A dictionary with chart layout configuration. - """ - return { - "position": {"row": 0, "column": 0}, - "size": {"width": 6, "height": 4}, - } + return {"position": {"row": 0, "column": 0}, "size": {"width": 6, "height": 4}} def test_add_chart_minimal( self, - chronicle_client: ChronicleClient, - response_mock: Mock, + chronicle_client: Mock, chart_layout: Dict[str, Any], ) -> None: """Test add_chart with minimal required parameters.""" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - display_name = "Test Chart" - - result = dashboard.add_chart( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - chart_layout=chart_layout, - ) - - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:addChart" - ) - expected_payload = { - "dashboardChart": { - "displayName": "Test Chart", - "tileType": "TILE_TYPE_VISUALIZATION", - }, - "chartLayout": { - "position": {"row": 0, "column": 0}, - "size": {"width": 6, "height": 4}, - }, - } - chronicle_client.session.post.assert_called_with( - url, json=expected_payload - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.add_chart( + chronicle_client, + dashboard_id="test-dashboard", + display_name="Test Chart", + chart_layout=chart_layout, + ) assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards/test-dashboard:addChart" + assert kwargs["json"]["dashboardChart"]["displayName"] == "Test Chart" + assert kwargs["json"]["chartLayout"] == chart_layout def test_add_chart_with_query( self, - chronicle_client: ChronicleClient, - response_mock: Mock, + chronicle_client: Mock, chart_layout: Dict[str, Any], ) -> None: """Test add_chart with query and interval parameters.""" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - display_name = "Test Chart" - query = 'udm.metadata.event_type = "PROCESS_LAUNCH"' - # Using InputInterval as imported from dashboard module - - interval = InputInterval( - relative_time={"timeUnit": "DAY", "startTimeVal": "1"} - ) - - result = dashboard.add_chart( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - chart_layout=chart_layout, - query=query, - interval=interval, - ) + interval = InputInterval(relative_time={"timeUnit": "DAY", "startTimeVal": "1"}) - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:addChart" - ) - payload = { - "dashboardChart": { - "displayName": "Test Chart", - "tileType": "TILE_TYPE_VISUALIZATION", - }, - "chartLayout": { - "position": {"row": 0, "column": 0}, - "size": {"width": 6, "height": 4}, - }, - "dashboardQuery": { - "query": 'udm.metadata.event_type = "PROCESS_LAUNCH"', - "input": { - "relativeTime": {"timeUnit": "DAY", "startTimeVal": "1"} - }, - }, - } - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.add_chart( + chronicle_client, + dashboard_id="test-dashboard", + display_name="Test Chart", + chart_layout=chart_layout, + query='udm.metadata.event_type = "PROCESS_LAUNCH"', + interval=interval, + ) assert result == {"name": "test-dashboard"} + body = mock_req.call_args.kwargs["json"] + assert body["dashboardQuery"]["query"] == 'udm.metadata.event_type = "PROCESS_LAUNCH"' + assert body["dashboardQuery"]["input"] == { + "relativeTime": {"timeUnit": "DAY", "startTimeVal": "1"} + } def test_add_chart_with_string_json_params( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test add_chart with string JSON parameters.""" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - display_name = "Test Chart" - chart_layout_str = ( - '{"position": {"row": 0, "column": 0}, "size": ' - '{"width": 6, "height": 4}}' - ) + chart_layout_str = '{"position": {"row": 0, "column": 0}, "size": {"width": 6, "height": 4}}' visualization_str = '{"type": "BAR_CHART"}' - result = dashboard.add_chart( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - chart_layout=chart_layout_str, - visualization=visualization_str, - ) - - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:addChart" - ) - payload = { - "dashboardChart": { - "displayName": "Test Chart", - "tileType": "TILE_TYPE_VISUALIZATION", - "visualization": {"type": "BAR_CHART"}, - }, - "chartLayout": { - "position": {"row": 0, "column": 0}, - "size": {"width": 6, "height": 4}, - }, - } - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.add_chart( + chronicle_client, + dashboard_id="test-dashboard", + display_name="Test Chart", + chart_layout=chart_layout_str, + visualization=visualization_str, + ) assert result == {"name": "test-dashboard"} + body = mock_req.call_args.kwargs["json"] + assert body["dashboardChart"]["visualization"] == {"type": "BAR_CHART"} + assert body["chartLayout"]["size"]["width"] == 6 def test_add_chart_error( self, - chronicle_client: ChronicleClient, - response_mock: Mock, + chronicle_client: Mock, chart_layout: Dict[str, Any], ) -> None: """Test add_chart function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - display_name = "Test Chart" - - with pytest.raises(APIError, match="Failed to add chart"): - dashboard.add_chart( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - chart_layout=chart_layout, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to add chart"), + ): + with pytest.raises(APIError, match="Failed to add chart"): + dashboard.add_chart( + chronicle_client, + dashboard_id="test-dashboard", + display_name="Test Chart", + chart_layout=chart_layout, + ) class TestDuplicateDashboard: """Test the duplicate_dashboard function.""" def test_duplicate_dashboard_minimal( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test duplicate_dashboard with minimal required parameters.""" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - display_name = "Duplicated Dashboard" - access_type = dashboard.DashboardAccessType.PRIVATE - - result = dashboard.duplicate_dashboard( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - access_type=access_type, - ) - - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:duplicate" - ) - payload = { - "nativeDashboard": { - "displayName": display_name, - "access": "DASHBOARD_PRIVATE", - "type": "CUSTOM", - } - } - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.duplicate_dashboard( + chronicle_client, + dashboard_id="test-dashboard", + display_name="Duplicated Dashboard", + access_type=dashboard.DashboardAccessType.PRIVATE, + ) assert result == {"name": "test-dashboard"} + kwargs = mock_req.call_args.kwargs + assert kwargs["endpoint_path"] == "nativeDashboards/test-dashboard:duplicate" + assert kwargs["json"]["nativeDashboard"]["access"] == "DASHBOARD_PRIVATE" def test_duplicate_dashboard_with_description( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test duplicate_dashboard with description parameter.""" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - display_name = "Duplicated Dashboard" - access_type = dashboard.DashboardAccessType.PUBLIC - description = "Duplicated dashboard description" - - result = dashboard.duplicate_dashboard( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - access_type=access_type, - description=description, - ) - - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:duplicate" - ) - payload = { - "nativeDashboard": { - "displayName": display_name, - "access": "DASHBOARD_PUBLIC", - "type": "CUSTOM", - "description": description, - } - } - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + result = dashboard.duplicate_dashboard( + chronicle_client, + dashboard_id="test-dashboard", + display_name="Duplicated Dashboard", + access_type=dashboard.DashboardAccessType.PUBLIC, + description="Duplicated dashboard description", + ) assert result == {"name": "test-dashboard"} + body = mock_req.call_args.kwargs["json"]["nativeDashboard"] + assert body["access"] == "DASHBOARD_PUBLIC" + assert body["description"] == "Duplicated dashboard description" def test_duplicate_dashboard_with_project_id( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test duplicate_dashboard with project ID in dashboard_id.""" - chronicle_client.session.post.return_value = response_mock - dashboard_id = ( - "projects/test-project/locations/test-location" - "/nativeDashboards/test-dashboard" - ) - display_name = "Duplicated Dashboard" - access_type = dashboard.DashboardAccessType.PRIVATE + full_id = "projects/test-project/locations/test-location/nativeDashboards/test-dashboard" - result = dashboard.duplicate_dashboard( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - access_type=access_type, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "test-dashboard"}, + ) as mock_req: + _ = dashboard.duplicate_dashboard( + chronicle_client, + dashboard_id=full_id, + display_name="Duplicated Dashboard", + access_type=dashboard.DashboardAccessType.PRIVATE, + ) - chronicle_client.session.post.assert_called_once() - expected_id = ( - "test-project/locations/test-location/nativeDashboards/" - "test-dashboard" - ) - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{expected_id}:duplicate" - ) - payload = { - "nativeDashboard": { - "displayName": display_name, - "access": "DASHBOARD_PRIVATE", - "type": "CUSTOM", - } - } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} + assert mock_req.call_args.kwargs["endpoint_path"] == "nativeDashboards/test-dashboard:duplicate" def test_duplicate_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test duplicate_dashboard function with error response.""" - response_mock.status_code = 404 - response_mock.text = "Dashboard not found" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "nonexistent-dashboard" - display_name = "Duplicated Dashboard" - access_type = dashboard.DashboardAccessType.PRIVATE - - with pytest.raises(APIError, match="Failed to duplicate dashboard"): - dashboard.duplicate_dashboard( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - access_type=access_type, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to duplicate dashboard"), + ): + with pytest.raises(APIError, match="Failed to duplicate dashboard"): + dashboard.duplicate_dashboard( + chronicle_client, + dashboard_id="nonexistent-dashboard", + display_name="Duplicated Dashboard", + access_type=dashboard.DashboardAccessType.PRIVATE, + ) class TestGetChart: """Test the get_chart function.""" def test_get_chart_success( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test get_chart function with successful response.""" - # Setup mock response - response_mock.json.return_value = { - "name": "projects/test-project/locations/test-location/dashboardCharts/test-chart", - "displayName": "Test Chart", - "visualization": {"type": "BAR_CHART"}, - } - chronicle_client.session.get.return_value = response_mock - chart_id = "test-chart" + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "dashboardCharts/test-chart", "displayName": "Test Chart"}, + ) as mock_req: + result = dashboard.get_chart(chronicle_client, "test-chart") - # Call function - result = dashboard.get_chart(chronicle_client, chart_id) - - # Verify API call - chronicle_client.session.get.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"dashboardCharts/{chart_id}" - ) - chronicle_client.session.get.assert_called_with(url) - - # Verify result - assert result["name"].endswith("/test-chart") assert result["displayName"] == "Test Chart" + assert mock_req.call_args.kwargs["endpoint_path"] == "dashboardCharts/test-chart" def test_get_chart_with_full_id( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test get_chart with full project path chart ID.""" - # Setup mock response - response_mock.json.return_value = { - "name": "projects/test-project/locations/test-location/dashboardCharts/test-chart", - "displayName": "Test Chart", - "visualization": {"type": "BAR_CHART"}, - } - chronicle_client.session.get.return_value = response_mock - - # Full project path chart ID - chart_id = "projects/test-project/locations/test-location/dashboardCharts/test-chart" - expected_id = "test-chart" + full = "projects/test-project/locations/test-location/dashboardCharts/test-chart" + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"displayName": "Test Chart"}, + ) as mock_req: + result = dashboard.get_chart(chronicle_client, full) - # Call function - result = dashboard.get_chart(chronicle_client, chart_id) - - # Verify API call uses the extracted ID - chronicle_client.session.get.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"dashboardCharts/{expected_id}" - ) - chronicle_client.session.get.assert_called_with(url) - - # Verify result assert result["displayName"] == "Test Chart" + assert mock_req.call_args.kwargs["endpoint_path"] == "dashboardCharts/test-chart" def test_get_chart_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test get_chart function with error response.""" - # Setup error response - response_mock.status_code = 404 - response_mock.text = "Chart not found" - chronicle_client.session.get.return_value = response_mock - chart_id = "nonexistent-chart" - - # Verify the function raises an APIError - with pytest.raises(APIError, match="Failed to get chart details"): - dashboard.get_chart(chronicle_client, chart_id) - - # Verify API call - chronicle_client.session.get.assert_called_once() + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to get chart details"), + ): + with pytest.raises(APIError, match="Failed to get chart details"): + dashboard.get_chart(chronicle_client, "nonexistent-chart") class TestEditChart: """Test the edit_chart function.""" def test_edit_chart_query( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test edit_chart with dashboard_query parameter.""" - # Setup mock response - response_mock.json.return_value = {"name": "updated-chart"} - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - - # Dashboard query to update dashboard_query = { "name": "projects/test-project/locations/test-location/dashboardQueries/test-query", "etag": "123456789", "query": 'udm.metadata.event_type = "NETWORK_CONNECTION"', - "input": { - "relative_time": {"timeUnit": "DAY", "startTimeVal": "7"} - }, + "input": {"relative_time": {"timeUnit": "DAY", "startTimeVal": "7"}}, } - # Call function - result = dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_query=dashboard_query, - ) - - # Verify API call - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" - ) - expected_payload = { - "dashboardQuery": dashboard_query, - "editMask": "dashboard_query.query,dashboard_query.input", - } - chronicle_client.session.post.assert_called_with( - url, json=expected_payload - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "updated-chart"}, + ) as mock_req: + result = dashboard.edit_chart( + chronicle_client, + dashboard_id="test-dashboard", + dashboard_query=dashboard_query, + ) assert result == {"name": "updated-chart"} + body = mock_req.call_args.kwargs["json"] + assert body["dashboardQuery"] == dashboard_query + assert body["editMask"] == "dashboard_query.query,dashboard_query.input" def test_edit_chart_details( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test edit_chart with dashboard_chart parameter.""" - # Setup mock response - response_mock.json.return_value = {"name": "updated-chart"} - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - - # Dashboard chart to update dashboard_chart = { "name": "projects/test-project/locations/test-location/dashboardCharts/test-chart", "etag": "123456789", @@ -1148,202 +842,155 @@ def test_edit_chart_details( "visualization": {"legends": [{"legendOrient": "HORIZONTAL"}]}, } - # Call function - result = dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_chart=dashboard_chart, - ) - - # Verify API call - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" - ) - expected_payload = { - "dashboardChart": dashboard_chart, - "editMask": "dashboard_chart.display_name,dashboard_chart.visualization", - } - chronicle_client.session.post.assert_called_with( - url, json=expected_payload - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "updated-chart"}, + ) as mock_req: + result = dashboard.edit_chart( + chronicle_client, + dashboard_id="test-dashboard", + dashboard_chart=dashboard_chart, + ) assert result == {"name": "updated-chart"} + body = mock_req.call_args.kwargs["json"] + assert body["dashboardChart"] == dashboard_chart + assert body["editMask"] == "dashboard_chart.display_name,dashboard_chart.visualization" def test_edit_chart_both( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test edit_chart with both query and chart parameters.""" - # Setup mock response - response_mock.json.return_value = {"name": "updated-chart"} - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - - # Dashboard query and chart to update dashboard_query = { "name": "projects/test-project/locations/test-location/dashboardQueries/test-query", "etag": "123456789", "query": 'udm.metadata.event_type = "NETWORK_CONNECTION"', - "input": { - "relative_time": {"timeUnit": "DAY", "startTimeVal": "7"} - }, + "input": {"relative_time": {"timeUnit": "DAY", "startTimeVal": "7"}}, } - dashboard_chart = { "name": "projects/test-project/locations/test-location/dashboardCharts/test-chart", "etag": "123456789", "display_name": "Updated Chart Title", } - # Call function - result = dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_query=dashboard_query, - dashboard_chart=dashboard_chart, - ) - - # Verify API call - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" - ) - expected_payload = { - "dashboardQuery": dashboard_query, - "dashboardChart": dashboard_chart, - "editMask": ( - "dashboard_query.query,dashboard_query.input," - "dashboard_chart.display_name" - ), - } - chronicle_client.session.post.assert_called_with( - url, json=expected_payload - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "updated-chart"}, + ) as mock_req: + result = dashboard.edit_chart( + chronicle_client, + dashboard_id="test-dashboard", + dashboard_query=dashboard_query, + dashboard_chart=dashboard_chart, + ) assert result == {"name": "updated-chart"} + body = mock_req.call_args.kwargs["json"] + assert body["dashboardQuery"] == dashboard_query + assert body["dashboardChart"] == dashboard_chart + assert body["editMask"] == ( + "dashboard_query.query,dashboard_query.input,dashboard_chart.display_name" + ) def test_edit_chart_with_model_objects( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test edit_chart with model objects instead of dictionaries.""" - # Setup mock response - response_mock.json.return_value = {"name": "updated-chart"} - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - - # Create model objects - interval = InputInterval( - relative_time={"timeUnit": "DAY", "startTimeVal": "3"} - ) - - dashboard_query = dashboard.DashboardQuery( + # Use the model classes from the module (exercise conversion paths) + interval = InputInterval(relative_time={"timeUnit": "DAY", "startTimeVal": "3"}) + dq = dashboard.DashboardQuery( name="test-query", etag="123456789", query='udm.metadata.event_type = "PROCESS_LAUNCH"', input=interval, ) - - dashboard_chart = dashboard.DashboardChart( + dc = dashboard.DashboardChart( name="test-chart", etag="123456789", display_name="Updated Chart", visualization={"type": "BAR_CHART"}, ) - # Call function - result = dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_query=dashboard_query, - dashboard_chart=dashboard_chart, - ) - - # Verify API call - chronicle_client.session.post.assert_called_once() - # We don't need to check exact payload here as the model objects - # handle the conversion, but we check the URL - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" - ) - chronicle_client.session.post.assert_called_with( - url, json=chronicle_client.session.post.call_args[1]["json"] - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value={"name": "updated-chart"}, + ) as mock_req: + result = dashboard.edit_chart( + chronicle_client, + dashboard_id="test-dashboard", + dashboard_query=dq, + dashboard_chart=dc, + ) assert result == {"name": "updated-chart"} + # Basic sanity: ensure we passed a dict payload (not model objects) + body = mock_req.call_args.kwargs["json"] + assert isinstance(body, dict) + assert "editMask" in body def test_edit_chart_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test edit_chart with error response.""" - # Setup error response - response_mock.status_code = 400 - response_mock.text = "Invalid request" - chronicle_client.session.post.return_value = response_mock - dashboard_id = "test-dashboard" - dashboard_query = { - "name": "projects/test-project/locations/test-location/dashboardQueries/test-query", - "etag": "123123123", - "query": "invalid query", - "input": { - "relative_time": {"timeUnit": "DAY", "startTimeVal": "7"} - }, - } - - # Verify the function raises an APIError - with pytest.raises(APIError, match="Failed to edit chart"): - dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_query=dashboard_query, - ) - - # Verify API call - chronicle_client.session.post.assert_called_once() + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to edit chart"), + ): + with pytest.raises(APIError, match="Failed to edit chart"): + dashboard.edit_chart( + chronicle_client, + dashboard_id="test-dashboard", + dashboard_query={ + "name": "projects/test-project/locations/test-location/dashboardQueries/test-query", + "etag": "123123123", + "query": "invalid query", + "input": { + "relative_time": { + "timeUnit": "DAY", + "startTimeVal": "7" + }, + }, + }, + ) class TestExportDashboard: """Test the export_dashboard function.""" def test_export_dashboard_success( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test export_dashboard function with successful response.""" - response_mock.json.return_value = { + upstream = { "inlineDestination": { - "dashboards": [ - {"dashboard": {"name": "test-dashboard-1"}}, - {"dashboard": {"name": "test-dashboard-2"}}, - ] + "dashboards": [{"dashboard": {"name": "test-dashboard-1"}}, {"dashboard": {"name": "test-dashboard-2"}}] } } - chronicle_client.session.post.return_value = response_mock - dashboard_names = ["test-dashboard-1", "test-dashboard-2"] - result = dashboard.export_dashboard(chronicle_client, dashboard_names) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=upstream, + ) as mock_req: + result = dashboard.export_dashboard(chronicle_client, ["test-dashboard-1", "test-dashboard-2"]) + + assert result == upstream - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards:export" - qualified_names = [ - f"{chronicle_client.instance_id}/nativeDashboards/test-dashboard-1", - f"{chronicle_client.instance_id}/nativeDashboards/test-dashboard-2", - ] - payload = {"names": qualified_names} - chronicle_client.session.post.assert_called_with(url, json=payload) + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards:export" - assert len(result["inlineDestination"]["dashboards"]) == 2 - assert result["inlineDestination"]["dashboards"][0]["dashboard"]["name"] == "test-dashboard-1" + # Ensure names are qualified the way export_dashboard builds them + names = kwargs["json"]["names"] + assert names[0].endswith("/nativeDashboards/test-dashboard-1") + assert names[1].endswith("/nativeDashboards/test-dashboard-2") def test_export_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test export_dashboard function with error response.""" - response_mock.status_code = 500 - response_mock.text = "Internal Server Error" - chronicle_client.session.post.return_value = response_mock - dashboard_names = ["test-dashboard-1"] - - with pytest.raises(APIError, match="Failed to export dashboards"): - dashboard.export_dashboard(chronicle_client, dashboard_names) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to export dashboards"), + ): + with pytest.raises(APIError, match="Failed to export dashboards"): + dashboard.export_dashboard(chronicle_client, ["test-dashboard-1"]) From 19e54dae53f54fd821fce961c5043090feffc4fd Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:09:47 +0530 Subject: [PATCH 09/13] feat: migrate dashboard methods to use request helpers and add as_list support. --- src/secops/chronicle/client.py | 12 +- src/secops/chronicle/dashboard.py | 292 +++--- src/secops/cli/commands/dashboard.py | 7 +- tests/chronicle/test_dashboard.py | 1363 ++++++++++++-------------- 4 files changed, 775 insertions(+), 899 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 2795f8bc..fa8b3b20 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -4158,20 +4158,28 @@ def list_dashboards( self, page_size: int | None = None, page_token: str | None = None, - ) -> dict[str, Any]: + as_list: bool = False, + ) -> dict[str, Any] | list[dict[str, Any]]: """List all available dashboards. Args: page_size: Maximum number of results to return page_token: Token for pagination + as_list: If True, return a list of dashboards instead of a dict + with dashboards list and nextPageToken. Returns: - Dictionary containing dashboard list and pagination info + If as_list is True: List of dashboards. + If as_list is False: Dict with dashboards list and nextPageToken. + + Raises: + APIError: If the API request fails """ return _list_dashboards( self, page_size=page_size, page_token=page_token, + as_list=as_list, ) def get_dashboard( diff --git a/src/secops/chronicle/dashboard.py b/src/secops/chronicle/dashboard.py index 4e12947e..ce0670b3 100644 --- a/src/secops/chronicle/dashboard.py +++ b/src/secops/chronicle/dashboard.py @@ -22,11 +22,16 @@ from typing import TYPE_CHECKING, Any from secops.chronicle.models import ( + APIVersion, DashboardChart, DashboardQuery, InputInterval, TileType, ) +from secops.chronicle.utils.request_utils import ( + chronicle_request, + chronicle_paginated_request, +) from secops.exceptions import APIError, SecOpsError if TYPE_CHECKING: @@ -83,8 +88,6 @@ def create_dashboard( Raises: APIError: If the API request fails """ - url = f"{client.base_url}/{client.instance_id}/nativeDashboards" - if filters and isinstance(filters, str): try: filters = json.loads(filters) @@ -104,7 +107,7 @@ def create_dashboard( payload = { "displayName": display_name, "definition": {}, - "access": access_type, + "access": access_type.value, "type": "CUSTOM", } @@ -117,15 +120,14 @@ def create_dashboard( if charts: payload["definition"]["charts"] = charts - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to create dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path="nativeDashboards", + api_version=APIVersion.V1ALPHA, + json=payload, + error_message="Failed to create dashboard", + ) def import_dashboard( @@ -143,8 +145,6 @@ def import_dashboard( Raises: APIError: If the API request fails """ - url = f"{client.base_url}/{client.instance_id}/nativeDashboards:import" - # Validate dashboard data keys valid_keys = ["dashboard", "dashboardCharts", "dashboardQueries"] dashboard_keys = set(dashboard.keys()) @@ -156,15 +156,14 @@ def import_dashboard( payload = {"source": {"dashboards": [dashboard]}} - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to import dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path="nativeDashboards:import", + api_version=APIVersion.V1ALPHA, + json=payload, + error_message="Failed to import dashboard", + ) def export_dashboard( @@ -183,8 +182,6 @@ def export_dashboard( Raises: APIError: If the API request fails. """ - url = f"{client.base_url}/{client.instance_id}/nativeDashboards:export" - # Ensure dashboard names are fully qualified qualified_names = [] for name in dashboard_names: @@ -194,48 +191,47 @@ def export_dashboard( payload = {"names": qualified_names} - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to export dashboards: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path="nativeDashboards:export", + api_version=APIVersion.V1ALPHA, + json=payload, + error_message="Failed to export dashboards", + ) def list_dashboards( client: "ChronicleClient", page_size: int | None = None, page_token: str | None = None, -) -> dict[str, Any]: + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: """List all available dashboards in Basic View. Args: client: ChronicleClient instance page_size: Maximum number of results to return page_token: Token for pagination + as_list: If True, return a list of dashboards instead of a dict + with dashboards list and nextPageToken. Returns: - Dictionary containing dashboard list and pagination info - """ - url = f"{client.base_url}/{client.instance_id}/nativeDashboards" - params = {} - if page_size: - params["pageSize"] = page_size - if page_token: - params["pageToken"] = page_token - - response = client.session.get(url, params=params) - - if response.status_code != 200: - raise APIError( - f"Failed to list dashboards: Status {response.status_code}, " - f"Response: {response.text}" - ) + If as_list is True: List of dashboards. + If as_list is False: Dict with dashboards list and nextPageToken. - return response.json() + Raises: + APIError: If the API request fails + """ + return chronicle_paginated_request( + client, + api_version=APIVersion.V1ALPHA, + path="nativeDashboards", + items_key="nativeDashboards", + page_size=page_size, + page_token=page_token, + as_list=as_list, + ) def get_dashboard( @@ -253,30 +249,26 @@ def get_dashboard( Returns: Dictionary containing dashboard details - """ + Raises: + APIError: If the API request fails + """ if dashboard_id.startswith("projects/"): dashboard_id = dashboard_id.split("projects/")[-1] - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) view = view or DashboardView.BASIC params = {"view": view.value} - response = client.session.get(url, params=params) - - if response.status_code != 200: - raise APIError( - f"Failed to get dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="GET", + endpoint_path=f"nativeDashboards/{dashboard_id}", + api_version=APIVersion.V1ALPHA, + params=params, + error_message=f"Failed to get dashboard: {dashboard_id}", + ) -# Updated update_dashboard function def update_dashboard( client: "ChronicleClient", dashboard_id: str, @@ -297,15 +289,13 @@ def update_dashboard( Returns: Dictionary containing the updated dashboard details + + Raises: + APIError: If the API request fails """ if dashboard_id.startswith("projects/"): dashboard_id = dashboard_id.split("projects/")[-1] - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - payload = {"definition": {}} update_mask = [] @@ -343,15 +333,15 @@ def update_dashboard( params = {"updateMask": ",".join(update_mask)} - response = client.session.patch(url, json=payload, params=params) - - if response.status_code != 200: - raise APIError( - f"Failed to update dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="PATCH", + endpoint_path=f"nativeDashboards/{dashboard_id}", + api_version=APIVersion.V1ALPHA, + json=payload, + params=params, + error_message=f"Failed to update dashboard: {dashboard_id}", + ) def delete_dashboard( @@ -365,26 +355,21 @@ def delete_dashboard( Returns: Empty dictionary on success - """ + Raises: + APIError: If the API request fails + """ if dashboard_id.startswith("projects/"): dashboard_id = dashboard_id.split("projects/")[-1] - url = ( - f"{client.base_url}/{client.instance_id}" - f"/nativeDashboards/{dashboard_id}" + return chronicle_request( + client, + method="DELETE", + endpoint_path=f"nativeDashboards/{dashboard_id}", + api_version=APIVersion.V1ALPHA, + error_message=f"Failed to delete dashboard: {dashboard_id}", ) - response = client.session.delete(url) - - if response.status_code != 200: - raise APIError( - f"Failed to delete dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return {"status": "success", "code": response.status_code} - def duplicate_dashboard( client: "ChronicleClient", @@ -405,15 +390,13 @@ def duplicate_dashboard( Returns: Dictionary containing the duplicated dashboard details + + Raises: + APIError: If the API request fails """ if dashboard_id.startswith("projects/"): dashboard_id = dashboard_id.split("projects/")[-1] - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}:duplicate" - ) - payload = { "nativeDashboard": { "displayName": display_name, @@ -425,15 +408,14 @@ def duplicate_dashboard( if description: payload["nativeDashboard"]["description"] = description - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to duplicate dashboard: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path=f"nativeDashboards/{dashboard_id}:duplicate", + api_version=APIVersion.V1ALPHA, + json=payload, + error_message=f"Failed to duplicate dashboard: {dashboard_id}", + ) def add_chart( @@ -468,18 +450,15 @@ def add_chart( **kwargs: Additional keyword arguments (It will be added to the request payload) - Returns: Dictionary containing the updated dashboard with new chart + + Raises: + APIError: If the API request fails """ if dashboard_id.startswith("projects/"): dashboard_id = dashboard_id.split("projects/")[-1] - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}:addChart" - ) - tile_type = TileType.VISUALIZATION if tile_type is None else tile_type # Convert JSON string to dictionary @@ -532,15 +511,14 @@ def add_chart( } ) - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to add chart: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path=f"nativeDashboards/{dashboard_id}:addChart", + api_version=APIVersion.V1ALPHA, + json=payload, + error_message=f"Failed to add chart to dashboard: {dashboard_id}", + ) def get_chart(client: "ChronicleClient", chart_id: str) -> dict[str, Any]: @@ -551,20 +529,21 @@ def get_chart(client: "ChronicleClient", chart_id: str) -> dict[str, Any]: chart_id: ID of the chart Returns: - Dict[str, Any]: Dictionary containing chart details + Dictionary containing chart details + + Raises: + APIError: If the API request fails """ if chart_id.startswith("projects/"): chart_id = chart_id.split("/")[-1] - url = f"{client.base_url}/{client.instance_id}/dashboardCharts/{chart_id}" - response = client.session.get(url) - - if response.status_code != 200: - raise APIError( - f"Failed to get chart details: Status {response.status_code}, " - f"Response: {response.text}" - ) - return response.json() + return chronicle_request( + client, + method="GET", + endpoint_path=f"dashboardCharts/{chart_id}", + api_version=APIVersion.V1ALPHA, + error_message=f"Failed to get chart details: {chart_id}", + ) def remove_chart( @@ -591,22 +570,16 @@ def remove_chart( if not chart_id.startswith("projects/"): chart_id = f"{client.instance_id}/dashboardCharts/{chart_id}" - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}:removeChart" - ) - payload = {"dashboardChart": chart_id} - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to remove chart: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path=f"nativeDashboards/{dashboard_id}:removeChart", + api_version=APIVersion.V1ALPHA, + json=payload, + error_message=f"Failed to remove chart from dashboard: {dashboard_id}", + ) def edit_chart( @@ -636,8 +609,12 @@ def edit_chart( "chartDatasource": { "dataSources":[]}, "etag": "123131231321321" } + Returns: Dictionary containing the updated dashboard with edited chart + + Raises: + APIError: If the API request fails """ if dashboard_id.startswith("projects/"): dashboard_id = dashboard_id.split("projects/")[-1] @@ -683,16 +660,11 @@ def edit_chart( payload["editMask"] = ",".join(update_fields) - url = ( - f"{client.base_url}/{client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" + return chronicle_request( + client, + method="POST", + endpoint_path=f"nativeDashboards/{dashboard_id}:editChart", + api_version=APIVersion.V1ALPHA, + json=payload, + error_message=f"Failed to edit chart in dashboard: {dashboard_id}", ) - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError( - f"Failed to edit chart: Status {response.status_code}, " - f"Response: {response.text}" - ) - - return response.json() diff --git a/src/secops/cli/commands/dashboard.py b/src/secops/cli/commands/dashboard.py index 3331ab39..dd6910a1 100644 --- a/src/secops/cli/commands/dashboard.py +++ b/src/secops/cli/commands/dashboard.py @@ -17,7 +17,7 @@ import json import sys -from secops.cli.utils.common_args import add_pagination_args +from secops.cli.utils.common_args import add_as_list_arg, add_pagination_args from secops.cli.utils.formatters import output_formatter from secops.exceptions import APIError, SecOpsError @@ -40,6 +40,7 @@ def setup_dashboard_command(subparsers): "list", help="List dashboards" ) add_pagination_args(list_parser) + add_as_list_arg(list_parser) list_parser.set_defaults(func=handle_dashboard_list_command) # Get dashboard @@ -375,7 +376,9 @@ def handle_dashboard_list_command(args, chronicle): """Handle list dashboards command.""" try: result = chronicle.list_dashboards( - page_size=args.page_size, page_token=args.page_token + page_size=args.page_size, + page_token=args.page_token, + as_list=args.as_list, ) output_formatter(result, args.output) except APIError as e: diff --git a/tests/chronicle/test_dashboard.py b/tests/chronicle/test_dashboard.py index 720e81b3..853312f5 100644 --- a/tests/chronicle/test_dashboard.py +++ b/tests/chronicle/test_dashboard.py @@ -27,35 +27,15 @@ @pytest.fixture -def chronicle_client() -> ChronicleClient: +def chronicle_client() -> Mock: """Create a mock Chronicle client for testing. Returns: A mock ChronicleClient instance. """ - with patch("secops.auth.SecOpsAuth") as mock_auth: - mock_session = Mock() - mock_session.headers = {} - mock_auth.return_value.session = mock_session - client = ChronicleClient( - customer_id="test-customer", project_id="test-project" - ) - client.base_url = "https://testapi.com" - client.instance_id = "test-project/locations/test-location" - return client - - -@pytest.fixture -def response_mock() -> Mock: - """Create a mock API response object. - - Returns: - A mock response object. - """ - mock = Mock() - mock.status_code = 200 - mock.json.return_value = {"name": "test-dashboard"} - return mock + client = Mock() + client.instance_id = "test-project/locations/test-location" + return client class TestDashboardEnums: @@ -75,304 +55,285 @@ def test_dashboard_access_type_enum(self) -> None: class TestGetDashboard: """Test the get_dashboard function.""" - def test_get_dashboard_success( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_get_dashboard_success(self, chronicle_client: Mock) -> None: """Test get_dashboard function with successful response.""" - chronicle_client.session.get.return_value = response_mock dashboard_id = "test-dashboard" - - result = dashboard.get_dashboard(chronicle_client, dashboard_id) - - chronicle_client.session.get.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"view": "NATIVE_DASHBOARD_VIEW_BASIC"} - chronicle_client.session.get.assert_called_with(url, params=params) - - assert result == {"name": "test-dashboard"} - - def test_get_dashboard_with_view( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + expected = {"name": "test-dashboard"} + + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.get_dashboard(chronicle_client, dashboard_id) + + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "GET" + assert kwargs["endpoint_path"] == f"nativeDashboards/{dashboard_id}" + assert kwargs["params"] == {"view": "NATIVE_DASHBOARD_VIEW_BASIC"} + + def test_get_dashboard_with_view(self, chronicle_client: Mock) -> None: """Test get_dashboard function with view parameter.""" - chronicle_client.session.get.return_value = response_mock dashboard_id = "test-dashboard" + expected = {"name": "test-dashboard"} + + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.get_dashboard( + chronicle_client, dashboard_id, view=DashboardView.FULL + ) - result = dashboard.get_dashboard( - chronicle_client, dashboard_id, view=DashboardView.FULL - ) - - chronicle_client.session.get.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"view": "NATIVE_DASHBOARD_VIEW_FULL"} - chronicle_client.session.get.assert_called_with(url, params=params) - - assert result == {"name": "test-dashboard"} + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "GET" + assert kwargs["endpoint_path"] == f"nativeDashboards/{dashboard_id}" + assert kwargs["params"] == {"view": "NATIVE_DASHBOARD_VIEW_FULL"} - def test_get_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_get_dashboard_error(self, chronicle_client: Mock) -> None: """Test get_dashboard function with error response.""" - response_mock.status_code = 404 - response_mock.text = "Dashboard not found" - chronicle_client.session.get.return_value = response_mock dashboard_id = "nonexistent-dashboard" - with pytest.raises(APIError, match="Failed to get dashboard"): - dashboard.get_dashboard(chronicle_client, dashboard_id) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to get dashboard"), + ): + with pytest.raises(APIError, match="Failed to get dashboard"): + dashboard.get_dashboard(chronicle_client, dashboard_id) class TestUpdateDashboard: """Test the update_dashboard function.""" def test_update_dashboard_display_name( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test update_dashboard with display_name parameter.""" - chronicle_client.session.patch.return_value = response_mock dashboard_id = "test-dashboard" display_name = "Updated Dashboard" + expected = {"name": "test-dashboard"} + + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.update_dashboard( + chronicle_client, dashboard_id, display_name=display_name + ) - result = dashboard.update_dashboard( - chronicle_client, dashboard_id, display_name=display_name - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "display_name"} - payload = {"displayName": display_name, "definition": {}} - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) - - assert result == {"name": "test-dashboard"} + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "PATCH" + assert kwargs["endpoint_path"] == f"nativeDashboards/{dashboard_id}" + assert kwargs["params"] == {"updateMask": "display_name"} + assert kwargs["json"] == {"displayName": display_name, "definition": {}} - def test_update_dashboard_description( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_update_dashboard_description(self, chronicle_client: Mock) -> None: """Test update_dashboard with description parameter.""" - chronicle_client.session.patch.return_value = response_mock dashboard_id = "test-dashboard" description = "Updated description" + expected = {"name": "test-dashboard"} + + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.update_dashboard( + chronicle_client, dashboard_id, description=description + ) - result = dashboard.update_dashboard( - chronicle_client, dashboard_id, description=description - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "description"} - payload = {"description": description, "definition": {}} - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) - - assert result == {"name": "test-dashboard"} + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "PATCH" + assert kwargs["endpoint_path"] == f"nativeDashboards/{dashboard_id}" + assert kwargs["params"] == {"updateMask": "description"} + assert kwargs["json"] == {"description": description, "definition": {}} - def test_update_dashboard_filters( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_update_dashboard_filters(self, chronicle_client: Mock) -> None: """Test update_dashboard with filters parameter.""" - chronicle_client.session.patch.return_value = response_mock dashboard_id = "test-dashboard" filters = [{"field": "event_type", "value": "PROCESS_LAUNCH"}] + expected = {"name": "test-dashboard"} + + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.update_dashboard( + chronicle_client, dashboard_id, filters=filters + ) - result = dashboard.update_dashboard( - chronicle_client, dashboard_id, filters=filters - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "definition.filters"} - payload = {"definition": {"filters": filters}} - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) - - assert result == {"name": "test-dashboard"} + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "PATCH" + assert kwargs["endpoint_path"] == f"nativeDashboards/{dashboard_id}" + assert kwargs["params"] == {"updateMask": "definition.filters"} + assert kwargs["json"] == {"definition": {"filters": filters}} - def test_update_dashboard_charts( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_update_dashboard_charts(self, chronicle_client: Mock) -> None: """Test update_dashboard with charts parameter.""" - chronicle_client.session.patch.return_value = response_mock dashboard_id = "test-dashboard" charts = [{"chart_id": "chart-1", "position": {"row": 0, "col": 0}}] + expected = {"name": "test-dashboard"} + + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.update_dashboard( + chronicle_client, dashboard_id, charts=charts + ) - result = dashboard.update_dashboard( - chronicle_client, dashboard_id, charts=charts - ) - - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "definition.charts"} - payload = {"definition": {"charts": charts}} - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) - - assert result == {"name": "test-dashboard"} + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "PATCH" + assert kwargs["endpoint_path"] == f"nativeDashboards/{dashboard_id}" + assert kwargs["params"] == {"updateMask": "definition.charts"} + assert kwargs["json"] == {"definition": {"charts": charts}} def test_update_dashboard_multiple_fields( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test update_dashboard with multiple parameters.""" - chronicle_client.session.patch.return_value = response_mock dashboard_id = "test-dashboard" display_name = "Updated Dashboard" description = "Updated description" + expected = {"name": "test-dashboard"} - result = dashboard.update_dashboard( - chronicle_client, - dashboard_id, - display_name=display_name, - description=description, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.update_dashboard( + chronicle_client, + dashboard_id, + display_name=display_name, + description=description, + ) - chronicle_client.session.patch.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - params = {"updateMask": "display_name,description"} - payload = { + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "PATCH" + assert kwargs["endpoint_path"] == f"nativeDashboards/{dashboard_id}" + assert kwargs["params"] == {"updateMask": "display_name,description"} + assert kwargs["json"] == { "displayName": display_name, "description": description, "definition": {}, } - chronicle_client.session.patch.assert_called_with( - url, json=payload, params=params - ) - assert result == {"name": "test-dashboard"} - - def test_update_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_update_dashboard_error(self, chronicle_client: Mock) -> None: """Test update_dashboard function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.patch.return_value = response_mock dashboard_id = "test-dashboard" - with pytest.raises(APIError, match="Failed to update dashboard"): - dashboard.update_dashboard( - chronicle_client, dashboard_id, display_name="Test" - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to update dashboard"), + ): + with pytest.raises(APIError, match="Failed to update dashboard"): + dashboard.update_dashboard( + chronicle_client, dashboard_id, display_name="Test" + ) class TestDeleteDashboard: """Test the delete_dashboard function.""" - def test_delete_dashboard_success( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_delete_dashboard_success(self, chronicle_client: Mock) -> None: """Test delete_dashboard function with successful response.""" - response_mock.json.return_value = {"status": "success", "code": 200} - chronicle_client.session.delete.return_value = response_mock dashboard_id = "test-dashboard" + expected = {"status": "success", "code": 200} - result = dashboard.delete_dashboard(chronicle_client, dashboard_id) - - chronicle_client.session.delete.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}" - ) - chronicle_client.session.delete.assert_called_with(url) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.delete_dashboard(chronicle_client, dashboard_id) - assert result == {"status": "success", "code": 200} + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "DELETE" + assert kwargs["endpoint_path"] == f"nativeDashboards/{dashboard_id}" def test_delete_dashboard_with_project_id( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test delete_dashboard with project ID in dashboard_id.""" - response_mock.json.return_value = {"status": "success", "code": 200} - chronicle_client.session.delete.return_value = response_mock dashboard_id = ( "projects/test-project/locations/test-location" "/nativeDashboards/test-dashboard" ) - - result = dashboard.delete_dashboard(chronicle_client, dashboard_id) - - chronicle_client.session.delete.assert_called_once() + expected = {"status": "success", "code": 200} expected_id = ( "test-project/locations/test-location/nativeDashboards/" "test-dashboard" ) - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{expected_id}" - ) - chronicle_client.session.delete.assert_called_with(url) - assert result == {"status": "success", "code": 200} + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.delete_dashboard(chronicle_client, dashboard_id) - def test_delete_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "DELETE" + assert kwargs["endpoint_path"] == f"nativeDashboards/{expected_id}" + + def test_delete_dashboard_error(self, chronicle_client: Mock) -> None: """Test delete_dashboard function with error response.""" - response_mock.status_code = 404 - response_mock.text = "Dashboard not found" - chronicle_client.session.delete.return_value = response_mock dashboard_id = "nonexistent-dashboard" - with pytest.raises(APIError, match="Failed to delete dashboard"): - dashboard.delete_dashboard(chronicle_client, dashboard_id) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to delete dashboard"), + ): + with pytest.raises(APIError, match="Failed to delete dashboard"): + dashboard.delete_dashboard(chronicle_client, dashboard_id) class TestRemoveChart: """Test the remove_chart function.""" - def test_remove_chart_success( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_remove_chart_success(self, chronicle_client: Mock) -> None: """Test remove_chart function with successful response.""" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" chart_id = "test-chart" + expected = {"name": "test-dashboard"} + + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.remove_chart( + chronicle_client, dashboard_id, chart_id + ) - result = dashboard.remove_chart( - chronicle_client, dashboard_id, chart_id - ) - - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:removeChart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:removeChart" ) - payload = { + assert kwargs["json"] == { "dashboardChart": "test-project/locations/test-location/" "dashboardCharts/test-chart" } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} - def test_remove_chart_with_full_ids( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_remove_chart_with_full_ids(self, chronicle_client: Mock) -> None: """Test remove_chart with full project IDs.""" - chronicle_client.session.post.return_value = response_mock dashboard_id = ( "projects/test-project/locations/test-location/" "nativeDashboards/test-dashboard" @@ -381,203 +342,199 @@ def test_remove_chart_with_full_ids( "projects/test-project/locations/test-location/" "dashboardCharts/test-chart" ) - - result = dashboard.remove_chart( - chronicle_client, dashboard_id, chart_id - ) - - chronicle_client.session.post.assert_called_once() - expected_id = ( + expected = {"name": "test-dashboard"} + expected_dashboard_id = ( "test-project/locations/test-location/nativeDashboards/" "test-dashboard" ) - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{expected_id}:removeChart" - ) - payload = {"dashboardChart": chart_id} - chronicle_client.session.post.assert_called_with(url, json=payload) - assert result == {"name": "test-dashboard"} + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.remove_chart( + chronicle_client, dashboard_id, chart_id + ) - def test_remove_chart_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{expected_dashboard_id}:removeChart" + ) + assert kwargs["json"] == {"dashboardChart": chart_id} + + def test_remove_chart_error(self, chronicle_client: Mock) -> None: """Test remove_chart function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" chart_id = "test-chart" - with pytest.raises(APIError, match="Failed to remove chart"): - dashboard.remove_chart(chronicle_client, dashboard_id, chart_id) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to remove chart"), + ): + with pytest.raises(APIError, match="Failed to remove chart"): + dashboard.remove_chart(chronicle_client, dashboard_id, chart_id) class TestListDashboards: """Test the list_dashboards function.""" - def test_list_dashboards_success( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_list_dashboards_success(self, chronicle_client: Mock) -> None: """Test list_dashboards function with successful response.""" - response_mock.json.return_value = { + expected = { "nativeDashboards": [ {"name": "test-dashboard-1"}, {"name": "test-dashboard-2"}, ] } - chronicle_client.session.get.return_value = response_mock - result = dashboard.list_dashboards(chronicle_client) + with patch( + "secops.chronicle.dashboard.chronicle_paginated_request", + return_value=expected, + ) as paged: + result = dashboard.list_dashboards(chronicle_client) - chronicle_client.session.get.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards" - chronicle_client.session.get.assert_called_with(url, params={}) - - assert len(result["nativeDashboards"]) == 2 - assert result["nativeDashboards"][0]["name"] == "test-dashboard-1" - assert result["nativeDashboards"][1]["name"] == "test-dashboard-2" + assert result == expected + paged.assert_called_once() + _, kwargs = paged.call_args + assert kwargs["path"] == "nativeDashboards" + assert kwargs["items_key"] == "nativeDashboards" def test_list_dashboards_with_pagination( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test list_dashboards function with pagination parameters.""" - # Mock the API response with pagination data - response_mock.json.return_value = { + expected = { "nativeDashboards": [ {"name": "test-dashboard-1"}, {"name": "test-dashboard-2"}, ], "nextPageToken": "next-page-token", } - chronicle_client.session.get.return_value = response_mock - - # Call the function with pagination parameters page_size = 10 page_token = "current-page-token" - result = dashboard.list_dashboards( - chronicle_client, page_size=page_size, page_token=page_token - ) - # Verify API call was made with correct parameters - chronicle_client.session.get.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards" - chronicle_client.session.get.assert_called_with( - url, params={"pageSize": page_size, "pageToken": page_token} - ) + with patch( + "secops.chronicle.dashboard.chronicle_paginated_request", + return_value=expected, + ) as paged: + result = dashboard.list_dashboards( + chronicle_client, page_size=page_size, page_token=page_token + ) - # Verify the returned data - assert len(result["nativeDashboards"]) == 2 - assert result["nativeDashboards"][0]["name"] == "test-dashboard-1" - assert result["nativeDashboards"][1]["name"] == "test-dashboard-2" - assert result["nextPageToken"] == "next-page-token" + assert result == expected + paged.assert_called_once() + _, kwargs = paged.call_args + assert kwargs["path"] == "nativeDashboards" + assert kwargs["items_key"] == "nativeDashboards" + assert kwargs["page_size"] == page_size + assert kwargs["page_token"] == page_token - def test_list_dashboards_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_list_dashboards_error(self, chronicle_client: Mock) -> None: """Test list_dashboards function with error response.""" - response_mock.status_code = 500 - response_mock.text = "Internal Server Error" - chronicle_client.session.get.return_value = response_mock - - with pytest.raises(APIError, match="Failed to list dashboards"): - dashboard.list_dashboards(chronicle_client) + with patch( + "secops.chronicle.dashboard.chronicle_paginated_request", + side_effect=APIError("Failed to list dashboards"), + ): + with pytest.raises(APIError, match="Failed to list dashboards"): + dashboard.list_dashboards(chronicle_client) class TestCreateDashboard: """Test the create_dashboard function.""" - def test_create_dashboard_minimal( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_create_dashboard_minimal(self, chronicle_client: Mock) -> None: """Test create_dashboard with minimal required parameters.""" - chronicle_client.session.post.return_value = response_mock display_name = "Test Dashboard" access_type = dashboard.DashboardAccessType.PRIVATE + expected = {"name": "test-dashboard"} - result = dashboard.create_dashboard( - chronicle_client, display_name=display_name, access_type=access_type - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.create_dashboard( + chronicle_client, + display_name=display_name, + access_type=access_type, + ) - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards" - payload = { + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards" + assert kwargs["json"] == { "displayName": display_name, "access": "DASHBOARD_PRIVATE", "type": "CUSTOM", "definition": {}, } - chronicle_client.session.post.assert_called_with(url, json=payload) - assert result == {"name": "test-dashboard"} - - def test_create_dashboard_full( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_create_dashboard_full(self, chronicle_client: Mock) -> None: """Test create_dashboard with all parameters.""" - chronicle_client.session.post.return_value = response_mock display_name = "Test Dashboard" access_type = dashboard.DashboardAccessType.PUBLIC description = "Test description" filters = [{"field": "event_type", "value": "PROCESS_LAUNCH"}] charts = [{"chart_id": "chart-1", "position": {"row": 0, "col": 0}}] + expected = {"name": "test-dashboard"} - result = dashboard.create_dashboard( - chronicle_client, - display_name=display_name, - access_type=access_type, - description=description, - filters=filters, - charts=charts, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.create_dashboard( + chronicle_client, + display_name=display_name, + access_type=access_type, + description=description, + filters=filters, + charts=charts, + ) - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards" - payload = { + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards" + assert kwargs["json"] == { "displayName": display_name, "access": "DASHBOARD_PUBLIC", "type": "CUSTOM", "description": description, "definition": {"filters": filters, "charts": charts}, } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} - def test_create_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_create_dashboard_error(self, chronicle_client: Mock) -> None: """Test create_dashboard function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.post.return_value = response_mock display_name = "Test Dashboard" access_type = dashboard.DashboardAccessType.PRIVATE - with pytest.raises(APIError, match="Failed to create dashboard"): - dashboard.create_dashboard( - chronicle_client, - display_name=display_name, - access_type=access_type, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to create dashboard"), + ): + with pytest.raises(APIError, match="Failed to create dashboard"): + dashboard.create_dashboard( + chronicle_client, + display_name=display_name, + access_type=access_type, + ) class TestImportDashboard: """Test the import_dashboard function.""" - def test_import_dashboard_success( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_import_dashboard_success(self, chronicle_client: Mock) -> None: """Test import_dashboard function with successful response.""" - # Setup mock response - response_mock.json.return_value = { + expected = { "name": "projects/test-project/locations/test-location/nativeDashboards/imported-dashboard", "displayName": "Imported Dashboard", } - chronicle_client.session.post.return_value = response_mock - - # Dashboard to import dashboard_data = { "dashboard": { "name": ( @@ -600,28 +557,24 @@ def test_import_dashboard_success( ], } - # Call the function - result = dashboard.import_dashboard(chronicle_client, dashboard_data) - - # Verify API call was made with correct parameters - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards:import" - payload = {"source": {"dashboards": [dashboard_data]}} - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.import_dashboard( + chronicle_client, dashboard_data + ) - # Verify the returned result - assert result["name"].endswith("/imported-dashboard") - assert result["displayName"] == "Imported Dashboard" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards:import" + assert kwargs["json"] == {"source": {"dashboards": [dashboard_data]}} - def test_import_dashboard_minimal( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_import_dashboard_minimal(self, chronicle_client: Mock) -> None: """Test import_dashboard function with minimal dashboard data.""" - # Setup mock response - response_mock.json.return_value = {"name": "test-dashboard"} - chronicle_client.session.post.return_value = response_mock - - # Minimal dashboard to import + expected = {"name": "test-dashboard"} dashboard_data = { "dashboard": { "name": ( @@ -634,28 +587,23 @@ def test_import_dashboard_minimal( "dashboardQueries": [], } - # Call the function - result = dashboard.import_dashboard(chronicle_client, dashboard_data) - - # Verify API call was made with correct parameters - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards:import" - payload = {"source": {"dashboards": [dashboard_data]}} - chronicle_client.session.post.assert_called_with(url, json=payload) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.import_dashboard( + chronicle_client, dashboard_data + ) - # Verify the returned result - assert result == {"name": "test-dashboard"} + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards:import" + assert kwargs["json"] == {"source": {"dashboards": [dashboard_data]}} - def test_import_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_import_dashboard_error(self, chronicle_client: Mock) -> None: """Test import_dashboard function with server error response.""" - # Setup server error response - response_mock.status_code = 500 - response_mock.text = "Internal Server Error" - chronicle_client.session.post.return_value = response_mock - - # Valid dashboard data dashboard_data = { "dashboard": { "name": ( @@ -678,25 +626,23 @@ def test_import_dashboard_error( ], } - # Verify the function raises an APIError - with pytest.raises(APIError, match="Failed to import dashboard"): - dashboard.import_dashboard(chronicle_client, dashboard_data) - - # Verify API call was attempted - chronicle_client.session.post.assert_called_once() + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to import dashboard"), + ): + with pytest.raises(APIError, match="Failed to import dashboard"): + dashboard.import_dashboard(chronicle_client, dashboard_data) def test_import_dashboard_invalid_data( - self, chronicle_client: ChronicleClient + self, chronicle_client: Mock ) -> None: """Test import_dashboard function with invalid dashboard data.""" - # Dashboard data without any of the required keys invalid_dashboard_data = { "displayName": "Invalid Dashboard", "access": "DASHBOARD_PUBLIC", "type": "CUSTOM", } - # Verify the function raises a SecOpsError with the correct message with pytest.raises( SecOpsError, match=( @@ -706,9 +652,6 @@ def test_import_dashboard_invalid_data( ): dashboard.import_dashboard(chronicle_client, invalid_dashboard_data) - # Verify no API call was attempted - chronicle_client.session.post.assert_not_called() - class TestAddChart: """Test the add_chart function.""" @@ -726,29 +669,33 @@ def chart_layout(self) -> Dict[str, Any]: } def test_add_chart_minimal( - self, - chronicle_client: ChronicleClient, - response_mock: Mock, - chart_layout: Dict[str, Any], + self, chronicle_client: Mock, chart_layout: Dict[str, Any] ) -> None: """Test add_chart with minimal required parameters.""" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" display_name = "Test Chart" + expected = {"name": "test-dashboard"} - result = dashboard.add_chart( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - chart_layout=chart_layout, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.add_chart( + chronicle_client, + dashboard_id=dashboard_id, + display_name=display_name, + chart_layout=chart_layout, + ) - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:addChart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:addChart" ) - expected_payload = { + assert kwargs["json"] == { "dashboardChart": { "displayName": "Test Chart", "tileType": "TILE_TYPE_VISUALIZATION", @@ -758,44 +705,41 @@ def test_add_chart_minimal( "size": {"width": 6, "height": 4}, }, } - chronicle_client.session.post.assert_called_with( - url, json=expected_payload - ) - - assert result == {"name": "test-dashboard"} def test_add_chart_with_query( - self, - chronicle_client: ChronicleClient, - response_mock: Mock, - chart_layout: Dict[str, Any], + self, chronicle_client: Mock, chart_layout: Dict[str, Any] ) -> None: """Test add_chart with query and interval parameters.""" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" display_name = "Test Chart" query = 'udm.metadata.event_type = "PROCESS_LAUNCH"' - # Using InputInterval as imported from dashboard module - interval = InputInterval( relative_time={"timeUnit": "DAY", "startTimeVal": "1"} ) + expected = {"name": "test-dashboard"} - result = dashboard.add_chart( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - chart_layout=chart_layout, - query=query, - interval=interval, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.add_chart( + chronicle_client, + dashboard_id=dashboard_id, + display_name=display_name, + chart_layout=chart_layout, + query=query, + interval=interval, + ) - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:addChart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:addChart" ) - payload = { + assert kwargs["json"] == { "dashboardChart": { "displayName": "Test Chart", "tileType": "TILE_TYPE_VISUALIZATION", @@ -811,15 +755,11 @@ def test_add_chart_with_query( }, }, } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} def test_add_chart_with_string_json_params( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test add_chart with string JSON parameters.""" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" display_name = "Test Chart" chart_layout_str = ( @@ -827,21 +767,29 @@ def test_add_chart_with_string_json_params( '{"width": 6, "height": 4}}' ) visualization_str = '{"type": "BAR_CHART"}' + expected = {"name": "test-dashboard"} - result = dashboard.add_chart( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - chart_layout=chart_layout_str, - visualization=visualization_str, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.add_chart( + chronicle_client, + dashboard_id=dashboard_id, + display_name=display_name, + chart_layout=chart_layout_str, + visualization=visualization_str, + ) - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:addChart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:addChart" ) - payload = { + assert kwargs["json"] == { "dashboardChart": { "displayName": "Test Chart", "tileType": "TILE_TYPE_VISUALIZATION", @@ -852,91 +800,95 @@ def test_add_chart_with_string_json_params( "size": {"width": 6, "height": 4}, }, } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} def test_add_chart_error( - self, - chronicle_client: ChronicleClient, - response_mock: Mock, - chart_layout: Dict[str, Any], + self, chronicle_client: Mock, chart_layout: Dict[str, Any] ) -> None: """Test add_chart function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" display_name = "Test Chart" - with pytest.raises(APIError, match="Failed to add chart"): - dashboard.add_chart( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - chart_layout=chart_layout, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to add chart"), + ): + with pytest.raises(APIError, match="Failed to add chart"): + dashboard.add_chart( + chronicle_client, + dashboard_id=dashboard_id, + display_name=display_name, + chart_layout=chart_layout, + ) class TestDuplicateDashboard: """Test the duplicate_dashboard function.""" - def test_duplicate_dashboard_minimal( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_duplicate_dashboard_minimal(self, chronicle_client: Mock) -> None: """Test duplicate_dashboard with minimal required parameters.""" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" display_name = "Duplicated Dashboard" access_type = dashboard.DashboardAccessType.PRIVATE + expected = {"name": "test-dashboard"} - result = dashboard.duplicate_dashboard( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - access_type=access_type, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.duplicate_dashboard( + chronicle_client, + dashboard_id=dashboard_id, + display_name=display_name, + access_type=access_type, + ) - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:duplicate" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:duplicate" ) - payload = { + assert kwargs["json"] == { "nativeDashboard": { "displayName": display_name, "access": "DASHBOARD_PRIVATE", "type": "CUSTOM", } } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} def test_duplicate_dashboard_with_description( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test duplicate_dashboard with description parameter.""" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" display_name = "Duplicated Dashboard" access_type = dashboard.DashboardAccessType.PUBLIC description = "Duplicated dashboard description" + expected = {"name": "test-dashboard"} - result = dashboard.duplicate_dashboard( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - access_type=access_type, - description=description, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.duplicate_dashboard( + chronicle_client, + dashboard_id=dashboard_id, + display_name=display_name, + access_type=access_type, + description=description, + ) - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:duplicate" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:duplicate" ) - payload = { + assert kwargs["json"] == { "nativeDashboard": { "displayName": display_name, "access": "DASHBOARD_PUBLIC", @@ -944,161 +896,133 @@ def test_duplicate_dashboard_with_description( "description": description, } } - chronicle_client.session.post.assert_called_with(url, json=payload) - - assert result == {"name": "test-dashboard"} def test_duplicate_dashboard_with_project_id( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test duplicate_dashboard with project ID in dashboard_id.""" - chronicle_client.session.post.return_value = response_mock dashboard_id = ( "projects/test-project/locations/test-location" "/nativeDashboards/test-dashboard" ) display_name = "Duplicated Dashboard" access_type = dashboard.DashboardAccessType.PRIVATE - - result = dashboard.duplicate_dashboard( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - access_type=access_type, - ) - - chronicle_client.session.post.assert_called_once() + expected = {"name": "test-dashboard"} expected_id = ( "test-project/locations/test-location/nativeDashboards/" "test-dashboard" ) - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{expected_id}:duplicate" + + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.duplicate_dashboard( + chronicle_client, + dashboard_id=dashboard_id, + display_name=display_name, + access_type=access_type, + ) + + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{expected_id}:duplicate" ) - payload = { + assert kwargs["json"] == { "nativeDashboard": { "displayName": display_name, "access": "DASHBOARD_PRIVATE", "type": "CUSTOM", } } - chronicle_client.session.post.assert_called_with(url, json=payload) - assert result == {"name": "test-dashboard"} - - def test_duplicate_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_duplicate_dashboard_error(self, chronicle_client: Mock) -> None: """Test duplicate_dashboard function with error response.""" - response_mock.status_code = 404 - response_mock.text = "Dashboard not found" - chronicle_client.session.post.return_value = response_mock dashboard_id = "nonexistent-dashboard" display_name = "Duplicated Dashboard" access_type = dashboard.DashboardAccessType.PRIVATE - with pytest.raises(APIError, match="Failed to duplicate dashboard"): - dashboard.duplicate_dashboard( - chronicle_client, - dashboard_id=dashboard_id, - display_name=display_name, - access_type=access_type, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to duplicate dashboard"), + ): + with pytest.raises(APIError, match="Failed to duplicate dashboard"): + dashboard.duplicate_dashboard( + chronicle_client, + dashboard_id=dashboard_id, + display_name=display_name, + access_type=access_type, + ) class TestGetChart: """Test the get_chart function.""" - def test_get_chart_success( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_get_chart_success(self, chronicle_client: Mock) -> None: """Test get_chart function with successful response.""" - # Setup mock response - response_mock.json.return_value = { + chart_id = "test-chart" + expected = { "name": "projects/test-project/locations/test-location/dashboardCharts/test-chart", "displayName": "Test Chart", "visualization": {"type": "BAR_CHART"}, } - chronicle_client.session.get.return_value = response_mock - chart_id = "test-chart" - - # Call function - result = dashboard.get_chart(chronicle_client, chart_id) - # Verify API call - chronicle_client.session.get.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"dashboardCharts/{chart_id}" - ) - chronicle_client.session.get.assert_called_with(url) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.get_chart(chronicle_client, chart_id) - # Verify result - assert result["name"].endswith("/test-chart") - assert result["displayName"] == "Test Chart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "GET" + assert kwargs["endpoint_path"] == f"dashboardCharts/{chart_id}" - def test_get_chart_with_full_id( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_get_chart_with_full_id(self, chronicle_client: Mock) -> None: """Test get_chart with full project path chart ID.""" - # Setup mock response - response_mock.json.return_value = { + chart_id = "projects/test-project/locations/test-location/dashboardCharts/test-chart" + expected_id = "test-chart" + expected = { "name": "projects/test-project/locations/test-location/dashboardCharts/test-chart", "displayName": "Test Chart", "visualization": {"type": "BAR_CHART"}, } - chronicle_client.session.get.return_value = response_mock - - # Full project path chart ID - chart_id = "projects/test-project/locations/test-location/dashboardCharts/test-chart" - expected_id = "test-chart" - - # Call function - result = dashboard.get_chart(chronicle_client, chart_id) - # Verify API call uses the extracted ID - chronicle_client.session.get.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"dashboardCharts/{expected_id}" - ) - chronicle_client.session.get.assert_called_with(url) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.get_chart(chronicle_client, chart_id) - # Verify result - assert result["displayName"] == "Test Chart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "GET" + assert kwargs["endpoint_path"] == f"dashboardCharts/{expected_id}" - def test_get_chart_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_get_chart_error(self, chronicle_client: Mock) -> None: """Test get_chart function with error response.""" - # Setup error response - response_mock.status_code = 404 - response_mock.text = "Chart not found" - chronicle_client.session.get.return_value = response_mock chart_id = "nonexistent-chart" - # Verify the function raises an APIError - with pytest.raises(APIError, match="Failed to get chart details"): - dashboard.get_chart(chronicle_client, chart_id) - - # Verify API call - chronicle_client.session.get.assert_called_once() + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to get chart details"), + ): + with pytest.raises(APIError, match="Failed to get chart details"): + dashboard.get_chart(chronicle_client, chart_id) class TestEditChart: """Test the edit_chart function.""" - def test_edit_chart_query( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_edit_chart_query(self, chronicle_client: Mock) -> None: """Test edit_chart with dashboard_query parameter.""" - # Setup mock response - response_mock.json.return_value = {"name": "updated-chart"} - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" - - # Dashboard query to update dashboard_query = { "name": "projects/test-project/locations/test-location/dashboardQueries/test-query", "etag": "123456789", @@ -1107,80 +1031,68 @@ def test_edit_chart_query( "relative_time": {"timeUnit": "DAY", "startTimeVal": "7"} }, } + expected = {"name": "updated-chart"} - # Call function - result = dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_query=dashboard_query, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.edit_chart( + chronicle_client, + dashboard_id=dashboard_id, + dashboard_query=dashboard_query, + ) - # Verify API call - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:editChart" ) - expected_payload = { + assert kwargs["json"] == { "dashboardQuery": dashboard_query, "editMask": "dashboard_query.query,dashboard_query.input", } - chronicle_client.session.post.assert_called_with( - url, json=expected_payload - ) - assert result == {"name": "updated-chart"} - - def test_edit_chart_details( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_edit_chart_details(self, chronicle_client: Mock) -> None: """Test edit_chart with dashboard_chart parameter.""" - # Setup mock response - response_mock.json.return_value = {"name": "updated-chart"} - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" - - # Dashboard chart to update dashboard_chart = { "name": "projects/test-project/locations/test-location/dashboardCharts/test-chart", "etag": "123456789", "display_name": "Updated Chart Title", "visualization": {"legends": [{"legendOrient": "HORIZONTAL"}]}, } + expected = {"name": "updated-chart"} - # Call function - result = dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_chart=dashboard_chart, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.edit_chart( + chronicle_client, + dashboard_id=dashboard_id, + dashboard_chart=dashboard_chart, + ) - # Verify API call - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:editChart" ) - expected_payload = { + assert kwargs["json"] == { "dashboardChart": dashboard_chart, "editMask": "dashboard_chart.display_name,dashboard_chart.visualization", } - chronicle_client.session.post.assert_called_with( - url, json=expected_payload - ) - assert result == {"name": "updated-chart"} - - def test_edit_chart_both( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_edit_chart_both(self, chronicle_client: Mock) -> None: """Test edit_chart with both query and chart parameters.""" - # Setup mock response - response_mock.json.return_value = {"name": "updated-chart"} - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" - - # Dashboard query and chart to update dashboard_query = { "name": "projects/test-project/locations/test-location/dashboardQueries/test-query", "etag": "123456789", @@ -1189,28 +1101,33 @@ def test_edit_chart_both( "relative_time": {"timeUnit": "DAY", "startTimeVal": "7"} }, } - dashboard_chart = { "name": "projects/test-project/locations/test-location/dashboardCharts/test-chart", "etag": "123456789", "display_name": "Updated Chart Title", } + expected = {"name": "updated-chart"} - # Call function - result = dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_query=dashboard_query, - dashboard_chart=dashboard_chart, - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.edit_chart( + chronicle_client, + dashboard_id=dashboard_id, + dashboard_query=dashboard_query, + dashboard_chart=dashboard_chart, + ) - # Verify API call - chronicle_client.session.post.assert_called_once() - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert ( + kwargs["endpoint_path"] + == f"nativeDashboards/{dashboard_id}:editChart" ) - expected_payload = { + assert kwargs["json"] == { "dashboardQuery": dashboard_query, "dashboardChart": dashboard_chart, "editMask": ( @@ -1218,70 +1135,45 @@ def test_edit_chart_both( "dashboard_chart.display_name" ), } - chronicle_client.session.post.assert_called_with( - url, json=expected_payload - ) - - assert result == {"name": "updated-chart"} def test_edit_chart_with_model_objects( - self, chronicle_client: ChronicleClient, response_mock: Mock + self, chronicle_client: Mock ) -> None: """Test edit_chart with model objects instead of dictionaries.""" - # Setup mock response - response_mock.json.return_value = {"name": "updated-chart"} - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" - - # Create model objects interval = InputInterval( relative_time={"timeUnit": "DAY", "startTimeVal": "3"} ) - dashboard_query = dashboard.DashboardQuery( name="test-query", etag="123456789", query='udm.metadata.event_type = "PROCESS_LAUNCH"', input=interval, ) - dashboard_chart = dashboard.DashboardChart( name="test-chart", etag="123456789", display_name="Updated Chart", visualization={"type": "BAR_CHART"}, ) + expected = {"name": "updated-chart"} - # Call function - result = dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_query=dashboard_query, - dashboard_chart=dashboard_chart, - ) - - # Verify API call - chronicle_client.session.post.assert_called_once() - # We don't need to check exact payload here as the model objects - # handle the conversion, but we check the URL - url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"nativeDashboards/{dashboard_id}:editChart" - ) - chronicle_client.session.post.assert_called_with( - url, json=chronicle_client.session.post.call_args[1]["json"] - ) + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.edit_chart( + chronicle_client, + dashboard_id=dashboard_id, + dashboard_query=dashboard_query, + dashboard_chart=dashboard_chart, + ) - assert result == {"name": "updated-chart"} + assert result == expected + req.assert_called_once() - def test_edit_chart_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_edit_chart_error(self, chronicle_client: Mock) -> None: """Test edit_chart with error response.""" - # Setup error response - response_mock.status_code = 400 - response_mock.text = "Invalid request" - chronicle_client.session.post.return_value = response_mock dashboard_id = "test-dashboard" dashboard_query = { "name": "projects/test-project/locations/test-location/dashboardQueries/test-query", @@ -1292,26 +1184,25 @@ def test_edit_chart_error( }, } - # Verify the function raises an APIError - with pytest.raises(APIError, match="Failed to edit chart"): - dashboard.edit_chart( - chronicle_client, - dashboard_id=dashboard_id, - dashboard_query=dashboard_query, - ) - - # Verify API call - chronicle_client.session.post.assert_called_once() + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to edit chart"), + ): + with pytest.raises(APIError, match="Failed to edit chart"): + dashboard.edit_chart( + chronicle_client, + dashboard_id=dashboard_id, + dashboard_query=dashboard_query, + ) class TestExportDashboard: """Test the export_dashboard function.""" - def test_export_dashboard_success( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + def test_export_dashboard_success(self, chronicle_client: Mock) -> None: """Test export_dashboard function with successful response.""" - response_mock.json.return_value = { + dashboard_names = ["test-dashboard-1", "test-dashboard-2"] + expected = { "inlineDestination": { "dashboards": [ {"dashboard": {"name": "test-dashboard-1"}}, @@ -1319,31 +1210,33 @@ def test_export_dashboard_success( ] } } - chronicle_client.session.post.return_value = response_mock - dashboard_names = ["test-dashboard-1", "test-dashboard-2"] - - result = dashboard.export_dashboard(chronicle_client, dashboard_names) - - chronicle_client.session.post.assert_called_once() - url = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/nativeDashboards:export" qualified_names = [ f"{chronicle_client.instance_id}/nativeDashboards/test-dashboard-1", f"{chronicle_client.instance_id}/nativeDashboards/test-dashboard-2", ] - payload = {"names": qualified_names} - chronicle_client.session.post.assert_called_with(url, json=payload) - assert len(result["inlineDestination"]["dashboards"]) == 2 - assert result["inlineDestination"]["dashboards"][0]["dashboard"]["name"] == "test-dashboard-1" + with patch( + "secops.chronicle.dashboard.chronicle_request", + return_value=expected, + ) as req: + result = dashboard.export_dashboard( + chronicle_client, dashboard_names + ) - def test_export_dashboard_error( - self, chronicle_client: ChronicleClient, response_mock: Mock - ) -> None: + assert result == expected + req.assert_called_once() + _, kwargs = req.call_args + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "nativeDashboards:export" + assert kwargs["json"] == {"names": qualified_names} + + def test_export_dashboard_error(self, chronicle_client: Mock) -> None: """Test export_dashboard function with error response.""" - response_mock.status_code = 500 - response_mock.text = "Internal Server Error" - chronicle_client.session.post.return_value = response_mock dashboard_names = ["test-dashboard-1"] - with pytest.raises(APIError, match="Failed to export dashboards"): - dashboard.export_dashboard(chronicle_client, dashboard_names) + with patch( + "secops.chronicle.dashboard.chronicle_request", + side_effect=APIError("Failed to export dashboards"), + ): + with pytest.raises(APIError, match="Failed to export dashboards"): + dashboard.export_dashboard(chronicle_client, dashboard_names) From 0336dbbebf7c9f168ad1dcf4e2fd53bd49762362 Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:14:30 +0530 Subject: [PATCH 10/13] chore: lint fix and formatting. --- src/secops/chronicle/client.py | 7 ++++--- src/secops/chronicle/dashboard.py | 23 ++++++++++++++++------ src/secops/chronicle/utils/format_utils.py | 9 ++++++--- tests/chronicle/utils/test_format_utils.py | 12 ++++++++--- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index f777f65b..c6923884 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -4186,7 +4186,8 @@ def list_dashboards( Returns: If as_list is True: List of dashboards. - If as_list is False: Dictionary containing list of dashboards and pagination info. + If as_list is False: Dictionary containing list of dashboards + and pagination info. Raises: APIError: If the API request fails @@ -4360,7 +4361,7 @@ def duplicate_dashboard( dashboard_id: ID of the dashboard to duplicate display_name: New name for the duplicated dashboard access_type: Access type for the duplicated dashboard - (DashboardAccessType.PRIVATE or DashboardAccessType.PUBLIC) + (DashboardAccessType.PRIVATE or DashboardAccessType.PUBLIC) description: Description for the duplicated dashboard api_version: Preferred API version to use. Defaults to V1ALPHA @@ -4407,7 +4408,7 @@ def remove_chart( self, dashboard_id=dashboard_id, chart_id=chart_id, - api_version=api_version + api_version=api_version, ) def get_chart( diff --git a/src/secops/chronicle/dashboard.py b/src/secops/chronicle/dashboard.py index a9f176c1..f7eb8077 100644 --- a/src/secops/chronicle/dashboard.py +++ b/src/secops/chronicle/dashboard.py @@ -153,7 +153,8 @@ def import_dashboard( """ if not any(key in dashboard for key in _VALID_DASHBOARD_KEYS): raise SecOpsError( - f'Dashboard must contain at least one of: {", ".join(_VALID_DASHBOARD_KEYS)}' + "Dashboard must contain at least one " + f'of: {", ".join(_VALID_DASHBOARD_KEYS)}' ) payload = {"source": {"dashboards": [dashboard]}} @@ -224,7 +225,8 @@ def list_dashboards( Returns: If as_list is True: List of dashboards. - If as_list is False: Dictionary containing list of dashboards and pagination info. + If as_list is False: Dictionary containing list of dashboards + and pagination info. Raises: APIError: If the API request fails @@ -275,6 +277,7 @@ def get_dashboard( error_message=f"Failed to get dashboard with ID {dashboard_id}", ) + def update_dashboard( client: "ChronicleClient", dashboard_id: str, @@ -300,7 +303,8 @@ def update_dashboard( Raises: ValueError: If no fields are provided to update - APIError: If filters or charts JSON is invalid, or if the API request fails + APIError: If filters or charts JSON is invalid, + or if the API request fails """ dashboard_id = format_resource_id(dashboard_id) @@ -524,7 +528,9 @@ def add_chart( endpoint_path=f"nativeDashboards/{dashboard_id}:addChart", api_version=api_version, json=payload, - error_message=f"Failed to add chart to dashboard with ID {dashboard_id}", + error_message=( + f"Failed to add chart to dashboard with ID {dashboard_id}" + ), ) @@ -585,7 +591,10 @@ def remove_chart( endpoint_path=f"nativeDashboards/{dashboard_id}:removeChart", api_version=api_version, json={"dashboardChart": chart_id}, - error_message=f"Failed to remove chart with ID {chart_id} from dashboard with ID {dashboard_id}", + error_message=( + f"Failed to remove chart with ID {chart_id} " + f"from dashboard with ID {dashboard_id}" + ), ) @@ -674,5 +683,7 @@ def edit_chart( endpoint_path=f"nativeDashboards/{dashboard_id}:editChart", api_version=api_version, json=payload, - error_message=f"Failed to edit chart in dashboard with ID {dashboard_id}", + error_message=( + f"Failed to edit chart in dashboard with ID {dashboard_id}" + ), ) diff --git a/src/secops/chronicle/utils/format_utils.py b/src/secops/chronicle/utils/format_utils.py index dd951d2c..b6567528 100644 --- a/src/secops/chronicle/utils/format_utils.py +++ b/src/secops/chronicle/utils/format_utils.py @@ -33,7 +33,8 @@ def format_resource_id(resource_id: str) -> str: resource_id: The full resource string or just the ID. Returns: - The extracted ID from the resource string, or the original string if it doesn't match the expected format. + The extracted ID from the resource string, + or the original string if it doesn't match the expected format. """ if resource_id.startswith("projects/"): return resource_id.split("/")[-1] @@ -46,11 +47,13 @@ def parse_json_list( """Parse a JSON string into a list, or return the list as-is. Args: - value: A list of dictionaries or a JSON string representing a list of dictionaries. + value: A list of dictionaries or + a JSON string representing a list of dictionaries. field_name: The name of the field being parsed, used for error messages. Returns: - A list of dictionaries parsed from the JSON string, or the original list if it was already a list. + A list of dictionaries parsed from the JSON string, + or the original list if it was already a list. Raises: APIError: If the input is a string but cannot be parsed as valid JSON. diff --git a/tests/chronicle/utils/test_format_utils.py b/tests/chronicle/utils/test_format_utils.py index 932800d6..c71bda40 100644 --- a/tests/chronicle/utils/test_format_utils.py +++ b/tests/chronicle/utils/test_format_utils.py @@ -17,7 +17,10 @@ import pytest -from secops.chronicle.utils.format_utils import format_resource_id, parse_json_list +from secops.chronicle.utils.format_utils import ( + format_resource_id, + parse_json_list, +) from secops.exceptions import APIError @@ -39,7 +42,10 @@ def test_format_resource_id_handles_minimal_projects_prefix() -> None: def test_format_resource_id_does_not_alter_non_projects_paths() -> None: # Paths that don't start with "projects/" should be returned as-is - assert format_resource_id("instances/my-instance/dashboards/abc") == "instances/my-instance/dashboards/abc" + assert ( + format_resource_id("instances/my-instance/dashboards/abc") + == "instances/my-instance/dashboards/abc" + ) def test_format_resource_id_empty_string_returns_empty_string() -> None: @@ -91,4 +97,4 @@ def test_parse_json_list_handles_empty_json_array() -> None: def test_parse_json_list_handles_empty_list_input() -> None: result = parse_json_list([], "filters") - assert result == [] \ No newline at end of file + assert result == [] From 84a128c4b5eb73baf1fa00d28a40e68b728bd37f Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:19:10 +0530 Subject: [PATCH 11/13] chore: minor doc string refactor --- src/secops/chronicle/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index c6923884..9c8d0463 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -4357,7 +4357,6 @@ def duplicate_dashboard( """Duplicate an existing dashboard. Args: - client: ChronicleClient instance dashboard_id: ID of the dashboard to duplicate display_name: New name for the duplicated dashboard access_type: Access type for the duplicated dashboard From bddd51dc057c60a9944290033d9eb3a7135d285e Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:49:38 +0530 Subject: [PATCH 12/13] chore: bump version to 0.35.3 and update changelog --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1550607d..012f5a64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.35.3] - 2026-03-03 +### Updated +- Dashboard methods to use centralized `chronicle_request` helper function for improved code consistency and maintainability + +### Added +- Helper functions for formatting dashboard resources +- Pagination helper for `list_dashboards` method + ## [0.35.2] - 2026-03-02 ### Added - `parse_statedump` parameter to `run_parser()` method for converting diff --git a/pyproject.toml b/pyproject.toml index 32f4bba7..a188733d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "secops" -version = "0.35.2" +version = "0.35.3" description = "Python SDK for wrapping the Google SecOps API for common use cases" readme = "README.md" requires-python = ">=3.10" From 1c97e29ba703844a52a162178e99ad6eab0569a3 Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:17:00 +0530 Subject: [PATCH 13/13] chore: fixed chart id in remove chart --- src/secops/chronicle/dashboard.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/secops/chronicle/dashboard.py b/src/secops/chronicle/dashboard.py index f7eb8077..33223acb 100644 --- a/src/secops/chronicle/dashboard.py +++ b/src/secops/chronicle/dashboard.py @@ -583,7 +583,9 @@ def remove_chart( APIError: If the API request fails """ dashboard_id = format_resource_id(dashboard_id) - chart_id = format_resource_id(chart_id) + + if not chart_id.startswith("projects/"): + chart_id = f"{client.instance_id}/dashboardCharts/{chart_id}" return chronicle_request( client,