diff --git a/README.md b/README.md index cf8aed4a4..ba149b8c8 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ When running againts multiple destination organizations, a seperate working dire | powerpacks | Sync Datadog powerpacks. | | restriction_policies | Sync Datadog restriction policies. | | roles | Sync Datadog roles. | +| rum_applications | Sync Datadog RUM applications. | | sensitive_data_scanner_groups | Sync SDS groups | | sensitive_data_scanner_groups_order | Sync SDS groups order | | sensitive_data_scanner_rules | Sync SDS rules | @@ -359,6 +360,7 @@ See [Supported resources](#supported-resources) section below for potential reso | powerpacks | monitors, service_level_objectives | | restriction_policies | dashboards, service_level_objectives, notebooks, users, roles | | roles | - | +| rum_applications | - | | sensitive_data_scanner_groups | - | | sensitive_data_scanner_groups_order | sensitive_data_scanner_groups | | sensitive_data_scanner_rules | sensitive_data_scanner_groups | @@ -367,7 +369,7 @@ See [Supported resources](#supported-resources) section below for potential reso | spans_metrics | - | | synthetics_global_variables | synthetics_tests | | synthetics_private_locations | - | -| synthetics_tests | synthetics_global_variables, roles | +| synthetics_tests | synthetics_global_variables, roles, rum_applications | | teams | - | | team_memberships | teams, users | | users | roles | diff --git a/datadog_sync/model/rum_applications.py b/datadog_sync/model/rum_applications.py index d20d01ae4..1efc4602f 100644 --- a/datadog_sync/model/rum_applications.py +++ b/datadog_sync/model/rum_applications.py @@ -7,11 +7,16 @@ from typing import TYPE_CHECKING, Optional, List, Dict, Tuple, cast from datadog_sync.utils.base_resource import BaseResource, ResourceConfig +from datadog_sync.utils.resource_utils import CustomClientHTTPError if TYPE_CHECKING: from datadog_sync.utils.custom_client import CustomClient +# Endpoint for RUM retention quota config (1:1 with application, scoped by app id). +_RETENTION_QUOTA_PATH = "/api/v2/rum/config/retention-quota/application" + + class RUMApplications(BaseResource): resource_type = "rum_applications" resource_config = ResourceConfig( @@ -35,11 +40,33 @@ class RUMApplications(BaseResource): "attributes.product_scales.rum_event_processing_scale.last_modified_at", "attributes.remote_config_id", "attributes.short_name", + # _retention_quota runtime fields (excluded from diffs/payloads) + "_retention_quota.attributes.org_id", + "_retention_quota.attributes.updated_at", + "_retention_quota.attributes.updated_by", ], + deep_diff_config={ + "ignore_order": True, + # _retention_quota.id is the app id (remapped by connect_resources); + # keep it in the resource for create/update but exclude from diff + # to avoid a perpetual diff on the id field. + "exclude_regex_paths": [r".*\['_retention_quota'\]\['id'\]"], + }, skip_resource_mapping=True, ) # Additional RUM Applications specific attributes + async def _fetch_retention_quota(self, client: CustomClient, app_id: str) -> Optional[Dict]: + """Fetch retention quota config for an application. Returns None on 404 + (no quota configured). Re-raises on other errors.""" + try: + resp = await client.get(f"{_RETENTION_QUOTA_PATH}/{app_id}") + return resp.get("data") + except CustomClientHTTPError as e: + if e.status_code == 404: + return None + raise + async def get_resources(self, client: CustomClient) -> List[Dict]: resp = await client.get(self.resource_config.base_path) @@ -48,6 +75,10 @@ async def get_resources(self, client: CustomClient) -> List[Dict]: for partial_resource in resp["data"]: partial_resource_id = partial_resource["id"] whole_resource = (await client.get(self.resource_config.base_path + f"/{partial_resource_id}"))["data"] + # Fetch retention quota config (1:1 with application). 404 = no quota set. + quota = await self._fetch_retention_quota(client, partial_resource_id) + if quota is not None: + whole_resource["_retention_quota"] = quota resources.append(whole_resource) return resources @@ -56,6 +87,10 @@ async def import_resource(self, _id: Optional[str] = None, resource: Optional[Di if _id: source_client = self.config.source_client resource = (await source_client.get(self.resource_config.base_path + f"/{_id}"))["data"] + # Fetch retention quota for this specific app + quota = await self._fetch_retention_quota(source_client, _id) + if quota is not None: + resource["_retention_quota"] = quota resource = cast(dict, resource) return resource["id"], resource @@ -66,12 +101,74 @@ async def pre_resource_action_hook(self, _id, resource: Dict) -> None: async def pre_apply_hook(self) -> None: pass + async def _sync_retention_quota(self, dest_app_id: str, resource: Dict) -> None: + """Create/update/delete the retention quota at the destination based + on the source state. Called after create/update of the application.""" + destination_client = self.config.destination_client + source_quota = resource.get("_retention_quota") + + # Check if destination already has a quota + dest_quota = await self._fetch_retention_quota(destination_client, dest_app_id) + + if source_quota is not None: + # Source has a quota -> PUT (create or update) + quota_payload = { + "data": { + "id": dest_app_id, + "type": "rum_quota_config", + "attributes": source_quota.get("attributes", {}), + } + } + # Strip runtime-only fields from the payload + attrs = quota_payload["data"]["attributes"] + attrs.pop("org_id", None) + attrs.pop("updated_at", None) + attrs.pop("updated_by", None) + await destination_client.put(f"{_RETENTION_QUOTA_PATH}/{dest_app_id}", quota_payload) + elif dest_quota is not None: + # Source has no quota but dest does -> DELETE + try: + await destination_client.delete(f"{_RETENTION_QUOTA_PATH}/{dest_app_id}") + except CustomClientHTTPError as e: + if e.status_code != 404: + raise + async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client + # Pop the quota before POSTing the application (it goes to a separate endpoint) + retention_quota = resource.pop("_retention_quota", None) resource["type"] = "rum_application_create" payload = {"data": resource} post_resp = await destination_client.post(self.resource_config.base_path, payload) - return _id, post_resp["data"] + data = post_resp["data"] + dest_app_id = data["id"] + + # After creating the app, sync the retention quota if source had one + if retention_quota is not None: + resource["_retention_quota"] = retention_quota # re-attach for state + try: + quota_payload = { + "data": { + "id": dest_app_id, + "type": "rum_quota_config", + "attributes": retention_quota.get("attributes", {}), + } + } + attrs = quota_payload["data"]["attributes"] + attrs.pop("org_id", None) + attrs.pop("updated_at", None) + attrs.pop("updated_by", None) + await destination_client.put(f"{_RETENTION_QUOTA_PATH}/{dest_app_id}", quota_payload) + except CustomClientHTTPError as e: + self.config.logger.warning(f"Failed to sync retention quota for app {dest_app_id}: {e}") + + # Store _retention_quota in the returned data so it persists in + # state.destination. Without this, the destination state never has + # _retention_quota, so the diff can't detect quota changes or removals. + if retention_quota is not None: + data["_retention_quota"] = retention_quota + + return _id, data async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client @@ -84,6 +181,9 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: self.config.logger.debug(f"{destination_id} not found, creating it") return await self.create_resource(_id, resource) + # Pop the quota before PATCHing the application (it goes to a separate endpoint) + retention_quota = resource.pop("_retention_quota", None) + # resource exists so we can update it resource["type"] = "rum_application_update" resource["id"] = destination_id @@ -92,10 +192,35 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: self.resource_config.base_path + "/" + destination_id, payload, ) - return _id, resp["data"] + data = resp["data"] + + # After updating the app, sync the retention quota. Always call this + # (not just when there's a quota change) so quota removal is handled: + # if source has no quota but dest does, _sync_retention_quota DELETEs it. + await self._sync_retention_quota( + destination_id, {"_retention_quota": retention_quota} if retention_quota else {} + ) + + # Store _retention_quota in the returned data so it persists in + # state.destination for future diff comparisons. + if retention_quota is not None: + data["_retention_quota"] = retention_quota + elif "_retention_quota" in data: + data.pop("_retention_quota", None) + + return _id, data async def delete_resource(self, _id: str) -> None: destination_client = self.config.destination_client - await destination_client.delete( - self.resource_config.base_path + f"/{self.config.state.destination[self.resource_type][_id]['id']}" - ) + dest_state = self.config.state.destination[self.resource_type][_id] + dest_app_id = dest_state["id"] + + # Delete retention quota first (ignore 404 if no quota exists) + try: + await destination_client.delete(f"{_RETENTION_QUOTA_PATH}/{dest_app_id}") + except CustomClientHTTPError as e: + if e.status_code != 404: + self.config.logger.warning(f"Failed to delete retention quota for app {dest_app_id}: {e}") + + # Then delete the application + await destination_client.delete(self.resource_config.base_path + f"/{dest_app_id}") diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import.yaml index 1dd1c19e8..ed7840922 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import.yaml @@ -52,6 +52,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import_per_file.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import_per_file.yaml index 38abe0aef..8d499d449 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import_per_file.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import_per_file.yaml @@ -72,6 +72,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync.yaml index 1ad7a726f..9b3dc6c3b 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync.yaml @@ -52,6 +52,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: '{"data": {"type": "rum_application_create", "attributes": {"apm_rum_flat_sampling_replay_enabled": true, "name": "Test RUM Application", "product_analytics_replay_sample_rate": @@ -9302,6 +9319,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: DELETE + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/1f5243c6-ced0-4298-98a4-efadd404c841 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9316,4 +9350,21 @@ interactions: status: code: 204 message: No Content +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/1f5243c6-ced0-4298-98a4-efadd404c841 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + version: 1 diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync_per_file.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync_per_file.yaml index c96f2c70c..55d933337 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync_per_file.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync_per_file.yaml @@ -72,6 +72,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9342,6 +9359,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: DELETE + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/27eecda9-0cdc-4010-b713-66058f4b5adc + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9356,4 +9390,21 @@ interactions: status: code: 204 message: No Content +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/27eecda9-0cdc-4010-b713-66058f4b5adc + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + version: 1 diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync.yaml index e1ef7342b..0032b1140 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync.yaml @@ -52,6 +52,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: '{"data": {"type": "rum_application_create", "attributes": {"apm_rum_flat_sampling_replay_enabled": true, "name": "Test RUM Application", "product_analytics_replay_sample_rate": @@ -132,6 +149,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/ff52fa91-d3a6-446c-8d05-82f04be1ba01 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: '{"data": {"type": "rum_application_update", "attributes": {"apm_rum_flat_sampling_replay_enabled": true, "name": "Test RUM Applicationupdated", "product_analytics_replay_sample_rate": @@ -161,6 +195,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/ff52fa91-d3a6-446c-8d05-82f04be1ba01 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9382,6 +9433,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: DELETE + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/ff52fa91-d3a6-446c-8d05-82f04be1ba01 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9396,4 +9464,21 @@ interactions: status: code: 204 message: No Content +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/ff52fa91-d3a6-446c-8d05-82f04be1ba01 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + version: 1 diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync_per_file.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync_per_file.yaml index afbe148ac..fd1d714a1 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync_per_file.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync_per_file.yaml @@ -72,6 +72,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -232,6 +249,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/44596ab5-af57-4a56-a8bf-b6aafb3a4af9 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: '{"data": {"type": "rum_application_update", "attributes": {"apm_rum_flat_sampling_replay_enabled": true, "name": "Test RUM Applicationupdated", "product_analytics_replay_sample_rate": @@ -261,6 +295,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/44596ab5-af57-4a56-a8bf-b6aafb3a4af9 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9522,6 +9573,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: DELETE + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/44596ab5-af57-4a56-a8bf-b6aafb3a4af9 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9536,4 +9604,21 @@ interactions: status: code: 204 message: No Content +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/44596ab5-af57-4a56-a8bf-b6aafb3a4af9 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + version: 1 diff --git a/tests/unit/test_rum_applications.py b/tests/unit/test_rum_applications.py new file mode 100644 index 000000000..8296469cb --- /dev/null +++ b/tests/unit/test_rum_applications.py @@ -0,0 +1,327 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the 3-clause BSD style license (see LICENSE). +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019 Datadog, Inc. + +""" +Unit tests for the existing RUMApplications resource model. + +Pins the documented behavior of rum_applications so the upcoming RUM resource +PRs can rely on it as a dependency without regressing the parent. The list +endpoint returns partial resources, so get_resources follows list-then-GET-each; +update_resource re-fetches the destination list and falls back to create when +the destination id is absent. Retention quota config (1:1 with application) is +embedded as _retention_quota and synced via a separate endpoint. +""" + +import asyncio +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock + +from datadog_sync.model.rum_applications import RUMApplications + + +def _run(coro): + # Fresh loop per call: pytest-asyncio strict mode closes the ambient loop + # between tests. See other test files in this suite for the pattern. + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _app_resource(_id, name="rum-app-src"): + return { + "id": _id, + "type": "rum_application", + "attributes": {"name": name}, + } + + +def _quota(app_id): + return { + "id": app_id, + "type": "rum_quota_config", + "attributes": { + "mode": "custom", + "custom": {"window_type": "daily", "session_limit": 1000000, "quota_reached_action": "stop"}, + }, + } + + +def test_get_resources_lists_then_gets_each(): + """The list endpoint returns partial resources; get_resources GETs each id + and returns the whole bodies in order. Retention quota GETs return 404.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + rum = RUMApplications(MagicMock()) + client = AsyncMock() + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "a1"}, {"id": "a2"}]} + elif url == "/api/v2/rum/applications/a1": + return {"data": _app_resource("a1")} + elif url == "/api/v2/rum/applications/a2": + return {"data": _app_resource("a2")} + elif url.startswith("/api/v2/rum/config/retention-quota/"): + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + client.get = AsyncMock(side_effect=mock_get) + + resources = _run(rum.get_resources(client)) + + assert [r["id"] for r in resources] == ["a1", "a2"] + # First call is the list, then one GET per id, then quota GETs (404 -> None) + assert client.get.await_count == 5 + assert client.get.await_args_list[0].args[0] == "/api/v2/rum/applications" + assert client.get.await_args_list[1].args[0] == "/api/v2/rum/applications/a1" + assert client.get.await_args_list[2].args[0] == "/api/v2/rum/config/retention-quota/application/a1" + assert client.get.await_args_list[3].args[0] == "/api/v2/rum/applications/a2" + assert client.get.await_args_list[4].args[0] == "/api/v2/rum/config/retention-quota/application/a2" + + +def test_get_resources_fetches_retention_quota(): + """When an app has a retention quota, get_resources embeds it as _retention_quota.""" + rum = RUMApplications(MagicMock()) + client = AsyncMock() + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "a1"}]} + elif url == "/api/v2/rum/applications/a1": + return {"data": _app_resource("a1")} + elif url == "/api/v2/rum/config/retention-quota/application/a1": + return {"data": _quota("a1")} + raise Exception(f"unexpected URL: {url}") + + client.get = AsyncMock(side_effect=mock_get) + + resources = _run(rum.get_resources(client)) + + assert len(resources) == 1 + assert resources[0]["_retention_quota"]["id"] == "a1" + + +def test_get_resources_handles_404_for_retention_quota(): + """When an app has no retention quota (404), _retention_quota is absent.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + rum = RUMApplications(MagicMock()) + client = AsyncMock() + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "a1"}]} + elif url == "/api/v2/rum/applications/a1": + return {"data": _app_resource("a1")} + elif url == "/api/v2/rum/config/retention-quota/application/a1": + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + client.get = AsyncMock(side_effect=mock_get) + + resources = _run(rum.get_resources(client)) + + assert len(resources) == 1 + assert "_retention_quota" not in resources[0] + + +def test_import_resource_by_id_gets_and_returns_id_and_data(): + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + rum = RUMApplications(MagicMock()) + source = AsyncMock() + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications/a1": + return {"data": _app_resource("a1")} + elif url == "/api/v2/rum/config/retention-quota/application/a1": + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + source.get = AsyncMock(side_effect=mock_get) + rum.config.source_client = source + + _id, data = _run(rum.import_resource(_id="a1")) + + assert _id == "a1" + assert data["id"] == "a1" + assert "_retention_quota" not in data + + +def test_import_resource_passthrough_when_resource_supplied(): + """When a full resource is supplied (no _id), no GET is performed.""" + rum = RUMApplications(MagicMock()) + rum.config.source_client = AsyncMock() + + resource = _app_resource("a1") + _id, data = _run(rum.import_resource(resource=resource)) + + assert _id == "a1" + assert data is resource + rum.config.source_client.get.assert_not_awaited() + + +def test_create_resource_sets_create_type_and_posts(): + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + dest.post = AsyncMock(return_value={"data": _app_resource("dst-1", name="rum-app-dst")}) + rum.config.destination_client = dest + + resource = _app_resource("a1") + _id, data = _run(rum.create_resource("a1", resource)) + + assert _id == "a1" + assert data["id"] == "dst-1" + # create mutates the type to the create-specific type and wraps in {"data": ...} + assert resource["type"] == "rum_application_create" + dest.post.assert_awaited_once() + post_url, post_payload = dest.post.await_args.args + assert post_url == "/api/v2/rum/applications" + assert post_payload == {"data": resource} + + +def test_create_resource_with_quota_puts_quota_after_create(): + """When the source resource has _retention_quota, create_resource PUTs the + quota to the retention-quota endpoint after creating the app.""" + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + dest.post = AsyncMock(return_value={"data": _app_resource("dst-1", name="rum-app-dst")}) + dest.put = AsyncMock(return_value={"data": _quota("dst-1")}) + rum.config.destination_client = dest + + resource = _app_resource("a1") + resource["_retention_quota"] = _quota("a1") + _id, data = _run(rum.create_resource("a1", resource)) + + assert _id == "a1" + # POST for the app, PUT for the quota + dest.post.assert_awaited_once() + dest.put.assert_awaited_once() + put_url, put_payload = dest.put.await_args.args + assert put_url == "/api/v2/rum/config/retention-quota/application/dst-1" + assert put_payload["data"]["id"] == "dst-1" + assert put_payload["data"]["type"] == "rum_quota_config" + # Runtime fields stripped from quota payload + assert "org_id" not in put_payload["data"]["attributes"] + assert "updated_at" not in put_payload["data"]["attributes"] + assert "updated_by" not in put_payload["data"]["attributes"] + + +def test_update_resource_patches_when_destination_id_exists(): + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "dst-1"}]} + elif url == "/api/v2/rum/applications/dst-1": + return {"data": _app_resource("dst-1", name="rum-app-dst")} + elif url == "/api/v2/rum/config/retention-quota/application/dst-1": + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + dest.get = AsyncMock(side_effect=mock_get) + dest.patch = AsyncMock(return_value={"data": _app_resource("dst-1", name="rum-app-dst-updated")}) + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_applications"]["a1"] = {"id": "dst-1"} + + resource = _app_resource("a1") + _id, data = _run(rum.update_resource("a1", resource)) + + assert _id == "a1" + assert data["id"] == "dst-1" + # update mutates the type to the update-specific type and rewrites the id. + assert resource["type"] == "rum_application_update" + assert resource["id"] == "dst-1" + dest.patch.assert_awaited_once() + patch_url, patch_payload = dest.patch.await_args.args + assert patch_url == "/api/v2/rum/applications/dst-1" + assert patch_payload == {"data": resource} + + +def test_update_resource_falls_back_to_create_when_destination_id_absent(): + """When the destination id is not present in a live re-fetch, update_resource + delegates to create_resource instead of PATCHing a stale id.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "other"}]} + elif url == "/api/v2/rum/applications/other": + return {"data": _app_resource("other")} + elif url.startswith("/api/v2/rum/config/retention-quota/"): + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + dest.get = AsyncMock(side_effect=mock_get) + dest.post = AsyncMock(return_value={"data": _app_resource("new-dst")}) + dest.patch = AsyncMock() + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_applications"]["a1"] = {"id": "stale-dst"} + + resource = _app_resource("a1") + _id, data = _run(rum.update_resource("a1", resource)) + + assert _id == "a1" + assert data["id"] == "new-dst" + dest.post.assert_awaited_once() + dest.patch.assert_not_awaited() + assert resource["type"] == "rum_application_create" + + +def test_delete_resource_deletes_quota_then_app(): + """delete_resource deletes the retention quota first (ignoring 404), then + the application.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + + def mock_delete(url, **kwargs): + if url.startswith("/api/v2/rum/config/retention-quota/"): + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + return None + + dest.delete = AsyncMock(side_effect=mock_delete) + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_applications"]["a1"] = {"id": "dst-1"} + + _run(rum.delete_resource("a1")) + + # Two deletes: quota (404 ignored), then app + assert dest.delete.await_count == 2 + assert dest.delete.await_args_list[0].args[0] == "/api/v2/rum/config/retention-quota/application/dst-1" + assert dest.delete.await_args_list[1].args[0] == "/api/v2/rum/applications/dst-1"