From 17e130ee2c80010a81a235312eb5404a908f4a79 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Mon, 28 Sep 2026 15:43:05 -0400 Subject: [PATCH 1/3] fix(sensitive_data_scanner_rules): align description with linked standard pattern on write The destination API rejects a standard-pattern-linked rule whose attributes.description does not match the linked destination pattern's canonical description (HTTP 400 'description of the standard rule and the rule must match'). The existing _align_name_with_standard_pattern (PR #627) aligned attributes.name but not attributes.description, so rules whose source org's canonical pattern description differed from the destination org's failed to create/update. Rename to _align_with_standard_pattern and extend it to also overwrite attributes.description with the destination pattern's canonical description on create/update, emitting a standard_pattern_description_rewrite metric per rewrite (mirroring the name-rewrite metric) so operators can audit drift. pre_apply_hook now also populates destination_standard_pattern_description_mapping (pattern_id -> description) alongside the existing name->id mapping. Applied only on create/update (not diffs/import) so source state is not silently mutated. No-op when no standard pattern relationship or when the pattern id is absent from the destination mappings. --- .../model/sensitive_data_scanner_rules.py | 115 +++++++---- .../unit/test_sensitive_data_scanner_rules.py | 188 ++++++++++++++++-- 2 files changed, 253 insertions(+), 50 deletions(-) diff --git a/datadog_sync/model/sensitive_data_scanner_rules.py b/datadog_sync/model/sensitive_data_scanner_rules.py index 209f2e9bd..db035edab 100644 --- a/datadog_sync/model/sensitive_data_scanner_rules.py +++ b/datadog_sync/model/sensitive_data_scanner_rules.py @@ -29,6 +29,7 @@ class SensitiveDataScannerRules(BaseResource): standard_pattern_path = "/api/v2/sensitive-data-scanner/standard-patterns" source_standard_pattern_mapping: Dict = {} # pattern_id -> pattern_name destination_standard_pattern_mapping: Dict = {} # pattern_name -> pattern_id + destination_standard_pattern_description_mapping: Dict = {} # pattern_id -> canonical description async def get_resources(self, client: CustomClient) -> List[Dict]: resp = await client.get(self.resource_config.base_path) @@ -68,17 +69,20 @@ async def pre_resource_action_hook(self, _id, resource: Dict) -> None: ) resource["relationships"]["standard_pattern"]["data"]["id"] = dest_id - async def _align_name_with_standard_pattern(self, _id: str, resource: Dict) -> None: + async def _align_with_standard_pattern(self, _id: str, resource: Dict) -> None: # Destination API rejects a standard-pattern-linked rule whose - # attributes.name does not match the linked pattern's canonical name. - # Overwrite the name on write and emit a metric so operators can - # audit drift. Applied only on create/update (not diffs/import) so - # source state is not silently mutated. By the time this runs, + # attributes.name or attributes.description does not match the linked + # destination pattern's canonical values (HTTP 400 'description of the + # standard rule and the rule must match' / name mismatch). Overwrite + # both on write and emit a metric per field so operators can audit + # drift. Applied only on create/update (not diffs/import) so source + # state is not silently mutated. By the time this runs, # pre_resource_action_hook has already replaced data.id with the # destination pattern uuid, so resolve the canonical name via the # destination mapping (name -> id) rather than trusting the id - # field to still hold a name string. - pattern_id = ((resource.get("relationships", {}).get("standard_pattern", {}).get("data") or {}).get("id")) + # field to still hold a name string, and the canonical description + # via destination_standard_pattern_description_mapping (id -> description). + pattern_id = (resource.get("relationships", {}).get("standard_pattern", {}).get("data") or {}).get("id") if not pattern_id: return pattern_name = next( @@ -88,57 +92,98 @@ async def _align_name_with_standard_pattern(self, _id: str, resource: Dict) -> N if not pattern_name: return attrs = resource.setdefault("attributes", {}) + + # Align attributes.name to the destination pattern's canonical name. source_name = attrs.get("name") - if not source_name or source_name == pattern_name: - return - attrs["name"] = pattern_name - self.config.logger.debug( - "%s %s: aligned attributes.name '%s' -> '%s' to match linked standard pattern", - self.resource_type, - _id, - source_name, - pattern_name, - ) - try: - await self.config.destination_client.send_metric( - Metrics.ACTION.value, - [ - f"id:{_id}", - f"resource_type:{self.resource_type}", - f"action_type:{Command.SYNC.value}", - "action_sub_type:standard_pattern_name_rewrite", - "status:success", - "client_type:destination", - f"pattern:{pattern_name}", - ], + if source_name and source_name != pattern_name: + attrs["name"] = pattern_name + self.config.logger.debug( + "%s %s: aligned attributes.name '%s' -> '%s' to match linked standard pattern", + self.resource_type, + _id, + source_name, + pattern_name, ) - except Exception as e: + try: + await self.config.destination_client.send_metric( + Metrics.ACTION.value, + [ + f"id:{_id}", + f"resource_type:{self.resource_type}", + f"action_type:{Command.SYNC.value}", + "action_sub_type:standard_pattern_name_rewrite", + "status:success", + "client_type:destination", + f"pattern:{pattern_name}", + ], + ) + except Exception as e: + self.config.logger.debug( + "Failed to send standard_pattern_name_rewrite metric for %s %s: %s", + self.resource_type, + _id, + e, + ) + + # Align attributes.description to the destination pattern's canonical + # description. The destination API rejects a standard-pattern-linked + # rule whose description differs from the linked pattern's description. + pattern_desc = self.destination_standard_pattern_description_mapping.get(pattern_id) + if pattern_desc is None: + return + source_desc = attrs.get("description") + if source_desc != pattern_desc: + attrs["description"] = pattern_desc self.config.logger.debug( - "Failed to send standard_pattern_name_rewrite metric for %s %s: %s", + "%s %s: aligned attributes.description to match linked standard pattern '%s'", self.resource_type, _id, - e, + pattern_name, ) + try: + await self.config.destination_client.send_metric( + Metrics.ACTION.value, + [ + f"id:{_id}", + f"resource_type:{self.resource_type}", + f"action_type:{Command.SYNC.value}", + "action_sub_type:standard_pattern_description_rewrite", + "status:success", + "client_type:destination", + f"pattern:{pattern_name}", + ], + ) + except Exception as e: + self.config.logger.debug( + "Failed to send standard_pattern_description_rewrite metric for %s %s: %s", + self.resource_type, + _id, + e, + ) async def pre_apply_hook(self) -> None: destination_client = self.config.destination_client if not self.destination_standard_pattern_mapping: mapping = {} - # Populate the standard pattern mapping + desc_mapping = {} + # Populate the standard pattern mapping (name -> id) and the + # canonical description mapping (id -> description). try: std_patterns = (await destination_client.get(self.resource_config.base_path + "/standard-patterns"))[ "data" ] for pattern in std_patterns: mapping[pattern["attributes"]["name"]] = pattern["id"] + desc_mapping[pattern["id"]] = pattern["attributes"].get("description", "") self.destination_standard_pattern_mapping = mapping + self.destination_standard_pattern_description_mapping = desc_mapping except Exception as e: self.config.logger.warning("error retrieving standard patterns: %s", e) async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client - await self._align_name_with_standard_pattern(_id, resource) + await self._align_with_standard_pattern(_id, resource) payload = {"data": resource, "meta": {}} resp = await destination_client.post(self.resource_config.base_path + "/rules", payload) @@ -147,7 +192,7 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client resource["id"] = self.config.state.destination[self.resource_type][_id]["id"] - await self._align_name_with_standard_pattern(_id, resource) + await self._align_with_standard_pattern(_id, resource) payload = {"data": resource, "meta": {}} await destination_client.patch( self.resource_config.base_path + f"/rules/{self.config.state.destination[self.resource_type][_id]['id']}", diff --git a/tests/unit/test_sensitive_data_scanner_rules.py b/tests/unit/test_sensitive_data_scanner_rules.py index 22a69b3e5..ca4c88040 100644 --- a/tests/unit/test_sensitive_data_scanner_rules.py +++ b/tests/unit/test_sensitive_data_scanner_rules.py @@ -184,11 +184,15 @@ def _make_rules(self, mapping=None): mock_config.destination_client = MagicMock() mock_config.destination_client.send_metric = AsyncMock() rules = SensitiveDataScannerRules(mock_config) - rules.destination_standard_pattern_mapping = mapping if mapping is not None else { - self.VISA_NAME: self.VISA_DEST_ID, - self.MC_NAME: self.MC_DEST_ID, - self.EMAIL_NAME: self.EMAIL_DEST_ID, - } + rules.destination_standard_pattern_mapping = ( + mapping + if mapping is not None + else { + self.VISA_NAME: self.VISA_DEST_ID, + self.MC_NAME: self.MC_DEST_ID, + self.EMAIL_NAME: self.EMAIL_DEST_ID, + } + ) return rules def _resource(self, _id, name, pattern_dest_id): @@ -197,29 +201,27 @@ def _resource(self, _id, name, pattern_dest_id): "type": "sensitive_data_scanner_rule", "attributes": {"name": name}, "relationships": { - "standard_pattern": { - "data": {"id": pattern_dest_id, "type": "sensitive_data_scanner_standard_pattern"} - } + "standard_pattern": {"data": {"id": pattern_dest_id, "type": "sensitive_data_scanner_standard_pattern"}} }, } def test_align_rewrites_name_when_mismatched(self): rules = self._make_rules() resource = self._resource("rule-1", "Custom Visa Scanner", self.VISA_DEST_ID) - asyncio.run(rules._align_name_with_standard_pattern("rule-1", resource)) + asyncio.run(rules._align_with_standard_pattern("rule-1", resource)) assert resource["attributes"]["name"] == self.VISA_NAME def test_align_noop_when_name_matches(self): rules = self._make_rules() resource = self._resource("rule-2", self.EMAIL_NAME, self.EMAIL_DEST_ID) - asyncio.run(rules._align_name_with_standard_pattern("rule-2", resource)) + asyncio.run(rules._align_with_standard_pattern("rule-2", resource)) assert resource["attributes"]["name"] == self.EMAIL_NAME rules.config.destination_client.send_metric.assert_not_called() def test_align_noop_when_source_name_is_empty(self): rules = self._make_rules() resource = self._resource("rule-3", "", self.VISA_DEST_ID) - asyncio.run(rules._align_name_with_standard_pattern("rule-3", resource)) + asyncio.run(rules._align_with_standard_pattern("rule-3", resource)) assert resource["attributes"]["name"] == "" rules.config.destination_client.send_metric.assert_not_called() @@ -231,7 +233,7 @@ def test_align_noop_when_no_standard_pattern(self): "attributes": {"name": "Custom SSN"}, "relationships": {"group": {"data": {"id": "grp"}}}, } - asyncio.run(rules._align_name_with_standard_pattern("custom", resource)) + asyncio.run(rules._align_with_standard_pattern("custom", resource)) assert resource["attributes"]["name"] == "Custom SSN" rules.config.destination_client.send_metric.assert_not_called() @@ -240,14 +242,14 @@ def test_align_noop_when_pattern_id_not_in_destination_mapping(self): # do not rewrite it to the raw destination uuid. rules = self._make_rules(mapping={}) resource = self._resource("rule-x", "Custom Visa", self.VISA_DEST_ID) - asyncio.run(rules._align_name_with_standard_pattern("rule-x", resource)) + asyncio.run(rules._align_with_standard_pattern("rule-x", resource)) assert resource["attributes"]["name"] == "Custom Visa" rules.config.destination_client.send_metric.assert_not_called() def test_align_emits_metric_with_expected_tags(self): rules = self._make_rules() resource = self._resource("rule-4", "Custom MC", self.MC_DEST_ID) - asyncio.run(rules._align_name_with_standard_pattern("rule-4", resource)) + asyncio.run(rules._align_with_standard_pattern("rule-4", resource)) rules.config.destination_client.send_metric.assert_awaited_once() metric_name, tags = rules.config.destination_client.send_metric.await_args.args assert metric_name == Metrics.ACTION.value @@ -263,7 +265,7 @@ def test_align_tolerates_metric_failure(self): rules = self._make_rules() rules.config.destination_client.send_metric = AsyncMock(side_effect=Exception("metric down")) resource = self._resource("rule-5", "Custom Visa", self.VISA_DEST_ID) - asyncio.run(rules._align_name_with_standard_pattern("rule-5", resource)) + asyncio.run(rules._align_with_standard_pattern("rule-5", resource)) assert resource["attributes"]["name"] == self.VISA_NAME def test_pre_resource_action_hook_does_not_rewrite_name(self): @@ -292,3 +294,159 @@ def test_create_path_yields_canonical_pattern_name_not_uuid(self): # attributes.name must be the canonical pattern NAME, not the destination uuid. assert resource["attributes"]["name"] == self.VISA_NAME assert resource["attributes"]["name"] != self.VISA_DEST_ID + + +class TestSensitiveDataScannerRulesCanonicalDescriptionRewrite: + """Rewrites attributes.description to the linked standard pattern's + canonical description on write, since the destination API rejects a + standard-pattern-linked rule whose description does not match the + linked destination pattern's description (HTTP 400 'description of the + standard rule and the rule must match'). Mirrors the name-rewrite + behavior. Emits a metric per rewrite for audit. Applied only on + create/update (not diffs/import) so source state stays untouched.""" + + # By the time _align_with_standard_pattern runs (from create/update), + # pre_resource_action_hook has already replaced data.id with the + # destination pattern uuid. So test inputs use the destination uuid and + # rely on destination_standard_pattern_mapping (name -> id) for reverse + # lookup of the name, and destination_standard_pattern_description_mapping + # (id -> description) for the canonical description. + VISA_NAME = "Visa Card Scanner (4x4 digits)" + VISA_DEST_ID = "dest-visa-uuid" + VISA_DESC = "Matches a sequence of characters representing a Visa card number." + MC_NAME = "MasterCard Scanner (4x4 digits)" + MC_DEST_ID = "dest-mc-uuid" + MC_DESC = "Matches a sequence of characters representing a MasterCard number." + EMAIL_NAME = "Email Address Scanner" + EMAIL_DEST_ID = "dest-email-uuid" + EMAIL_DESC = "Matches a sequence of characters representing an email address." + + def _make_rules(self, name_mapping=None, desc_mapping=None): + mock_config = MagicMock() + mock_config.state = MagicMock() + mock_config.destination_client = MagicMock() + mock_config.destination_client.send_metric = AsyncMock() + rules = SensitiveDataScannerRules(mock_config) + rules.destination_standard_pattern_mapping = ( + name_mapping + if name_mapping is not None + else { + self.VISA_NAME: self.VISA_DEST_ID, + self.MC_NAME: self.MC_DEST_ID, + self.EMAIL_NAME: self.EMAIL_DEST_ID, + } + ) + rules.destination_standard_pattern_description_mapping = ( + desc_mapping + if desc_mapping is not None + else { + self.VISA_DEST_ID: self.VISA_DESC, + self.MC_DEST_ID: self.MC_DESC, + self.EMAIL_DEST_ID: self.EMAIL_DESC, + } + ) + return rules + + def _resource(self, _id, description, pattern_dest_id, name="Custom Rule"): + return { + "id": _id, + "type": "sensitive_data_scanner_rule", + "attributes": {"name": name, "description": description}, + "relationships": { + "standard_pattern": {"data": {"id": pattern_dest_id, "type": "sensitive_data_scanner_standard_pattern"}} + }, + } + + def test_align_rewrites_description_when_mismatched(self): + rules = self._make_rules() + resource = self._resource("rule-1", "custom desc", self.VISA_DEST_ID) + asyncio.run(rules._align_with_standard_pattern("rule-1", resource)) + assert resource["attributes"]["description"] == self.VISA_DESC + + def test_align_noop_when_description_matches(self): + rules = self._make_rules() + resource = self._resource("rule-2", self.EMAIL_DESC, self.EMAIL_DEST_ID, name=self.EMAIL_NAME) + asyncio.run(rules._align_with_standard_pattern("rule-2", resource)) + assert resource["attributes"]["description"] == self.EMAIL_DESC + rules.config.destination_client.send_metric.assert_not_called() + + def test_align_noop_when_description_empty_and_pattern_empty(self): + rules = self._make_rules( + desc_mapping={self.VISA_DEST_ID: ""}, + ) + resource = self._resource("rule-3", "", self.VISA_DEST_ID, name=self.VISA_NAME) + asyncio.run(rules._align_with_standard_pattern("rule-3", resource)) + assert resource["attributes"]["description"] == "" + rules.config.destination_client.send_metric.assert_not_called() + + def test_align_noop_when_no_standard_pattern(self): + rules = self._make_rules() + resource = { + "id": "custom", + "type": "sensitive_data_scanner_rule", + "attributes": {"name": "Custom SSN", "description": "custom ssn desc"}, + "relationships": {"group": {"data": {"id": "grp"}}}, + } + asyncio.run(rules._align_with_standard_pattern("custom", resource)) + assert resource["attributes"]["description"] == "custom ssn desc" + rules.config.destination_client.send_metric.assert_not_called() + + def test_align_noop_when_pattern_id_not_in_destination_mapping(self): + # If we cannot resolve the canonical description, do not touch the + # description — do not rewrite it to None. + rules = self._make_rules(name_mapping={self.VISA_NAME: self.VISA_DEST_ID}, desc_mapping={}) + resource = self._resource("rule-x", "Custom Visa desc", self.VISA_DEST_ID, name=self.VISA_NAME) + asyncio.run(rules._align_with_standard_pattern("rule-x", resource)) + assert resource["attributes"]["description"] == "Custom Visa desc" + rules.config.destination_client.send_metric.assert_not_called() + + def test_align_emits_description_metric_with_expected_tags(self): + rules = self._make_rules() + resource = self._resource("rule-4", "custom mc desc", self.MC_DEST_ID) + asyncio.run(rules._align_with_standard_pattern("rule-4", resource)) + rules.config.destination_client.send_metric.assert_awaited() + # Both name (if mismatched) and description metrics may fire; find the + # description one. + calls = rules.config.destination_client.send_metric.await_args_list + desc_calls = [c for c in calls if "action_sub_type:standard_pattern_description_rewrite" in c.args[1]] + assert len(desc_calls) == 1 + metric_name, tags = desc_calls[0].args + assert metric_name == Metrics.ACTION.value + assert "id:rule-4" in tags + assert "resource_type:sensitive_data_scanner_rules" in tags + assert "action_type:sync" in tags + assert "action_sub_type:standard_pattern_description_rewrite" in tags + assert "status:success" in tags + assert "client_type:destination" in tags + assert f"pattern:{self.MC_NAME}" in tags + + def test_align_tolerates_description_metric_failure(self): + rules = self._make_rules() + rules.config.destination_client.send_metric = AsyncMock(side_effect=Exception("metric down")) + resource = self._resource("rule-5", "custom visa desc", self.VISA_DEST_ID) + asyncio.run(rules._align_with_standard_pattern("rule-5", resource)) + assert resource["attributes"]["description"] == self.VISA_DESC + + def test_create_path_yields_canonical_description(self): + # End-to-end: pre_resource_action_hook translates data.id + # source-name -> destination-uuid, then create_resource calls + # _align_with_standard_pattern which must set attributes.description + # to the destination pattern's canonical description. + rules = self._make_rules() + rules.config.destination_client.post = AsyncMock(return_value={"data": {"id": "created"}}) + # Post-import shape: data.id holds the source pattern's canonical name. + resource = self._resource("rule-e2e", "source-side visa desc", self.VISA_NAME, name=self.VISA_NAME) + asyncio.run(rules.pre_resource_action_hook("rule-e2e", resource)) + asyncio.run(rules.create_resource("rule-e2e", resource)) + assert resource["attributes"]["description"] == self.VISA_DESC + + def test_align_rewrites_both_name_and_description_when_both_mismatched(self): + rules = self._make_rules() + resource = self._resource("rule-both", "custom desc", self.MC_DEST_ID, name="Custom MC") + asyncio.run(rules._align_with_standard_pattern("rule-both", resource)) + assert resource["attributes"]["name"] == self.MC_NAME + assert resource["attributes"]["description"] == self.MC_DESC + calls = rules.config.destination_client.send_metric.await_args_list + sub_types = {tag for c in calls for tag in c.args[1] if tag.startswith("action_sub_type:")} + assert "action_sub_type:standard_pattern_name_rewrite" in sub_types + assert "action_sub_type:standard_pattern_description_rewrite" in sub_types From cb6bfeadc610e98dd824f0d76a197f85d054a7c5 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Mon, 28 Sep 2026 15:43:05 -0400 Subject: [PATCH 2/3] fix(sensitive_data_scanner_rules): guard pre_apply_hook on both mappings Address review suggestion: pre_apply_hook now repopulates when EITHER destination_standard_pattern_mapping or destination_standard_pattern_description_mapping is empty, so a partial-cache state (name mapping present, description mapping empty) cannot skip description initialization in long-lived process or future refactor scenarios. Adds 4 regression tests for the partial-cache guard. --- .../model/sensitive_data_scanner_rules.py | 5 +- .../unit/test_sensitive_data_scanner_rules.py | 63 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/datadog_sync/model/sensitive_data_scanner_rules.py b/datadog_sync/model/sensitive_data_scanner_rules.py index db035edab..cc1df7acf 100644 --- a/datadog_sync/model/sensitive_data_scanner_rules.py +++ b/datadog_sync/model/sensitive_data_scanner_rules.py @@ -163,7 +163,10 @@ async def _align_with_standard_pattern(self, _id: str, resource: Dict) -> None: async def pre_apply_hook(self) -> None: destination_client = self.config.destination_client - if not self.destination_standard_pattern_mapping: + # Guard on both mappings so a partial-cache state (e.g. name mapping + # populated but description mapping empty from a prior run or future + # refactor) cannot skip description initialization. + if not self.destination_standard_pattern_mapping or not self.destination_standard_pattern_description_mapping: mapping = {} desc_mapping = {} # Populate the standard pattern mapping (name -> id) and the diff --git a/tests/unit/test_sensitive_data_scanner_rules.py b/tests/unit/test_sensitive_data_scanner_rules.py index ca4c88040..05f3c10c6 100644 --- a/tests/unit/test_sensitive_data_scanner_rules.py +++ b/tests/unit/test_sensitive_data_scanner_rules.py @@ -450,3 +450,66 @@ def test_align_rewrites_both_name_and_description_when_both_mismatched(self): sub_types = {tag for c in calls for tag in c.args[1] if tag.startswith("action_sub_type:")} assert "action_sub_type:standard_pattern_name_rewrite" in sub_types assert "action_sub_type:standard_pattern_description_rewrite" in sub_types + + +class TestSensitiveDataScannerRulesPreApplyHookPartialCache: + """pre_apply_hook must repopulate when EITHER mapping is empty, so a + partial-cache state (name mapping present, description mapping empty) + cannot skip description initialization.""" + + VISA_NAME = "Visa Card Scanner (4x4 digits)" + VISA_DEST_ID = "dest-visa-uuid" + VISA_DESC = "Matches a sequence of characters representing a Visa card number." + + def _make_rules(self, name_mapping=None, desc_mapping=None): + mock_config = MagicMock() + mock_config.state = MagicMock() + mock_config.destination_client = MagicMock() + mock_config.destination_client.get = AsyncMock( + return_value={ + "data": [ + { + "id": self.VISA_DEST_ID, + "type": "sensitive_data_scanner_standard_pattern", + "attributes": {"name": self.VISA_NAME, "description": self.VISA_DESC}, + } + ] + } + ) + rules = SensitiveDataScannerRules(mock_config) + rules.destination_standard_pattern_mapping = name_mapping if name_mapping is not None else {} + rules.destination_standard_pattern_description_mapping = desc_mapping if desc_mapping is not None else {} + return rules + + def test_repopulates_when_both_mappings_empty(self): + rules = self._make_rules(name_mapping={}, desc_mapping={}) + asyncio.run(rules.pre_apply_hook()) + assert rules.destination_standard_pattern_mapping == {self.VISA_NAME: self.VISA_DEST_ID} + assert rules.destination_standard_pattern_description_mapping == {self.VISA_DEST_ID: self.VISA_DESC} + + def test_repopulates_when_only_name_mapping_present(self): + # Partial cache: name mapping populated, description mapping empty. + # Must still re-fetch so description mapping is initialized. + rules = self._make_rules( + name_mapping={self.VISA_NAME: self.VISA_DEST_ID}, + desc_mapping={}, + ) + asyncio.run(rules.pre_apply_hook()) + assert rules.destination_standard_pattern_description_mapping == {self.VISA_DEST_ID: self.VISA_DESC} + + def test_repopulates_when_only_description_mapping_present(self): + # Partial cache: description mapping populated, name mapping empty. + rules = self._make_rules( + name_mapping={}, + desc_mapping={self.VISA_DEST_ID: self.VISA_DESC}, + ) + asyncio.run(rules.pre_apply_hook()) + assert rules.destination_standard_pattern_mapping == {self.VISA_NAME: self.VISA_DEST_ID} + + def test_skips_refetch_when_both_mappings_populated(self): + rules = self._make_rules( + name_mapping={self.VISA_NAME: self.VISA_DEST_ID}, + desc_mapping={self.VISA_DEST_ID: self.VISA_DESC}, + ) + asyncio.run(rules.pre_apply_hook()) + rules.config.destination_client.get.assert_not_called() From 0cc8d5fcf713406e9b147afa61ca19313340fc20 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Mon, 28 Sep 2026 14:41:35 -0400 Subject: [PATCH 3/3] ci: retrigger tests (arm/windows runner timeout)