diff --git a/infrahub_sync/adapters/infrahub.py b/infrahub_sync/adapters/infrahub.py index 0ba62b7..58f6fdc 100644 --- a/infrahub_sync/adapters/infrahub.py +++ b/infrahub_sync/adapters/infrahub.py @@ -94,6 +94,23 @@ def resolve_peer_node( return peer_node +def _relationship_input_data(peer_id: str | None, source: str | None, owner: str | None) -> dict[str, Any]: + """Build relationship input data (peer id + optional source/owner attribution). + + Assigning a plain peer node to a relationship leaves its ``source``/``owner`` + metadata unset, so a relationship updated by a sync would carry no lineage + back to the sync's source/owner. Passing this dict to ``setattr`` (one) or + ``RelationshipManagerSync.add`` (many) stamps the same attribution the + attribute-update path applies, matching what the create path already does. + """ + data: dict[str, Any] = {"id": peer_id} + if source: + data["source"] = source + if owner: + data["owner"] = owner + return data + + def update_node( node: InfrahubNodeSync, attrs: Mapping[str, Any], @@ -109,8 +126,8 @@ def update_node( Args: node: The node to update. attrs: The attributes and relationships to update. - source: Optional source ID to set on updated attributes. - owner: Optional owner ID to set on updated attributes. + source: Optional source ID to set on updated attributes and relationships. + owner: Optional owner ID to set on updated attributes and relationships. """ schemas: Mapping[str, MainSchemaTypesAPI] = node._client.schema.all(branch=node._branch) for attr_name, attr_value in attrs.items(): @@ -141,7 +158,9 @@ def update_node( if not peer_node: logger.warning("Unable to find %s [%s] in the Store - Ignored", rel_schema.peer, attr_value) continue - setattr(node, attr_name, peer_node) + # Assign via a data dict (not the bare peer) so source/owner + # attribution is stamped on the updated relationship. + setattr(node, attr_name, _relationship_input_data(peer_node.id, source, owner)) else: # TODO: delete the old relationship data ? pass @@ -172,7 +191,9 @@ def update_node( attr_manager.remove(existing_id) for new_id in new_only: - attr_manager.add(new_id) + # Add via a data dict so source/owner attribution is stamped + # on the newly added relationship peer. + attr_manager.add(_relationship_input_data(new_id, source, owner)) return node diff --git a/tests/adapters/test_infrahub_update_node_attribution.py b/tests/adapters/test_infrahub_update_node_attribution.py new file mode 100644 index 0000000..4d36d71 --- /dev/null +++ b/tests/adapters/test_infrahub_update_node_attribution.py @@ -0,0 +1,277 @@ +"""Tests for source/owner attribution on relationships in ``update_node``. + +Regression coverage for the bug where ``update_node`` stamped ``source``/ +``owner`` metadata onto updated **attributes** but not onto updated +**relationships** (opsmill/infrahub-sync#142). A relationship changed by a sync +must now carry the same attribution as an attribute changed in the same update, +matching the create path. + +The tests use lightweight stand-ins for ``InfrahubNodeSync`` — ``update_node`` +only touches a handful of members — and monkeypatch ``resolve_peer_node`` so no +SDK/network plumbing is needed. Two tests additionally build a *real* +``RelatedNodeSync`` from the helper's output to prove the attribution actually +serialises into (or stays out of) the GraphQL mutation input. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from infrahub_sync.adapters import infrahub as infrahub_adapter +from infrahub_sync.adapters.infrahub import _relationship_input_data, update_node # noqa: PLC2701 + +SOURCE_ID = "source-account-id" +OWNER_ID = "owner-account-id" + + +# --------------------------------------------------------------------------- +# Lightweight stand-ins for ``InfrahubNodeSync`` and its schema +# --------------------------------------------------------------------------- + + +@dataclass +class FakeAttr: + """Attribute manager stand-in — ``update_node`` sets ``value``/``source``/``owner``.""" + + value: Any = None + source: Any = None + owner: Any = None + + +@dataclass +class FakeRelSchema: + """Relationship schema stand-in.""" + + name: str + peer: str + cardinality: str + + +@dataclass +class FakeSchema: + attribute_names: list[str] = field(default_factory=list) + relationships: list[FakeRelSchema] = field(default_factory=list) + relationship_names: list[str] = field(default_factory=list) + + +class FakeSchemaClient: + """Stand-in for ``client.schema`` — only ``all()`` is used.""" + + def __init__(self, peers: dict[str, object]) -> None: + self._peers = peers + + def all(self, branch: str | None = None) -> dict[str, object]: # noqa: ARG002 + return self._peers + + +class FakeClient: + def __init__(self, peers: dict[str, object]) -> None: + self.schema = FakeSchemaClient(peers) + self.store = object() + + +class FakeRelManager: + """Cardinality-many manager stand-in — records add/remove calls.""" + + def __init__(self, existing_ids: list[str] | None = None) -> None: + self.peer_ids = list(existing_ids or []) + self.initialized = True + self.added: list[object] = [] + self.removed: list[str] = [] + + def fetch(self) -> None: + self.initialized = True + + def add(self, data: object) -> None: + self.added.append(data) + + def remove(self, peer_id: str) -> None: + self.removed.append(peer_id) + + +class FakeNode: + """Stand-in for ``InfrahubNodeSync`` exposing only what ``update_node`` reads.""" + + def __init__( + self, + schema: FakeSchema, + client: FakeClient, + attr_holders: dict[str, FakeAttr] | None = None, + many_managers: dict[str, FakeRelManager] | None = None, + ) -> None: + self._schema = schema + self._client = client + self._branch = "main" + for name, holder in (attr_holders or {}).items(): + setattr(self, name, holder) + for name, manager in (many_managers or {}).items(): + setattr(self, name, manager) + + +def _run_update(node: FakeNode, attrs: dict[str, object], source: str | None = None, owner: str | None = None) -> None: + """Call ``update_node`` on a duck-typed fake node (single scoped type suppression). + + ``update_node`` is annotated for ``InfrahubNodeSync`` but only touches members + ``FakeNode`` provides, so the type mismatch is suppressed here once rather than + at every call site (mirrors ``_serialise`` in test_infrahub_node_to_diffsync). + """ + update_node(node, attrs, source=source, owner=owner) # ty: ignore[invalid-argument-type] + + +@pytest.fixture +def patch_resolve_peer(monkeypatch: pytest.MonkeyPatch) -> None: + """Make ``resolve_peer_node`` return a peer whose ``id`` echoes the lookup key.""" + + def _fake_resolve(key: str, **_kwargs: object) -> MagicMock: + peer = MagicMock() + peer.id = key + return peer + + monkeypatch.setattr(infrahub_adapter, "resolve_peer_node", _fake_resolve) + + +# --------------------------------------------------------------------------- +# _relationship_input_data — the helper +# --------------------------------------------------------------------------- + + +def test_relationship_input_data_includes_source_and_owner() -> None: + assert _relationship_input_data("peer-id", SOURCE_ID, OWNER_ID) == { + "id": "peer-id", + "source": SOURCE_ID, + "owner": OWNER_ID, + } + + +@pytest.mark.parametrize( + ("source", "owner", "expected"), + [ + (None, None, {"id": "peer-id"}), + (SOURCE_ID, None, {"id": "peer-id", "source": SOURCE_ID}), + (None, OWNER_ID, {"id": "peer-id", "owner": OWNER_ID}), + ], +) +def test_relationship_input_data_omits_unset_attribution( + source: str | None, owner: str | None, expected: dict[str, str] +) -> None: + assert _relationship_input_data("peer-id", source, owner) == expected + + +def test_helper_output_serialises_attribution_via_real_sdk() -> None: + """A real RelatedNodeSync built from the helper emits ``_relation__source/owner``.""" + from infrahub_sdk.node.related_node import RelatedNodeSync + + data = _relationship_input_data("peer-id", SOURCE_ID, OWNER_ID) + rel = RelatedNodeSync(client=None, branch="main", schema=MagicMock(), data=data) # ty: ignore[invalid-argument-type] + assert rel._generate_input_data() == { + "id": "peer-id", + "_relation__source": SOURCE_ID, + "_relation__owner": OWNER_ID, + } + + +def test_helper_output_without_attribution_has_no_relation_metadata() -> None: + """With no source/owner, the mutation input carries only the peer id.""" + from infrahub_sdk.node.related_node import RelatedNodeSync + + data = _relationship_input_data("peer-id", None, None) + rel = RelatedNodeSync(client=None, branch="main", schema=MagicMock(), data=data) # ty: ignore[invalid-argument-type] + assert rel._generate_input_data() == {"id": "peer-id"} + + +# --------------------------------------------------------------------------- +# update_node — attributes (regression: unchanged behaviour) +# --------------------------------------------------------------------------- + + +def test_update_node_attribute_gets_source_and_owner() -> None: + holder = FakeAttr() + schema = FakeSchema(attribute_names=["position"]) + node = FakeNode(schema=schema, client=FakeClient(peers={}), attr_holders={"position": holder}) + + _run_update(node, {"position": 5}, source=SOURCE_ID, owner=OWNER_ID) + + assert holder.value == 5 + assert holder.source is not None + assert holder.owner is not None + + +def test_update_node_attribute_no_attribution_when_unset() -> None: + holder = FakeAttr() + schema = FakeSchema(attribute_names=["position"]) + node = FakeNode(schema=schema, client=FakeClient(peers={}), attr_holders={"position": holder}) + + _run_update(node, {"position": 5}) + + assert holder.value == 5 + assert holder.source is None + assert holder.owner is None + + +# --------------------------------------------------------------------------- +# update_node — cardinality-one relationship (the fix) +# --------------------------------------------------------------------------- + + +def test_update_node_relationship_one_gets_attribution(patch_resolve_peer: None) -> None: # noqa: ARG001 + rel = FakeRelSchema(name="location", peer="LocationRack", cardinality="one") + schema = FakeSchema(relationships=[rel], relationship_names=["location"]) + node = FakeNode(schema=schema, client=FakeClient(peers={"LocationRack": object()})) + + _run_update(node, {"location": "rack-uid"}, source=SOURCE_ID, owner=OWNER_ID) + + assert node.__dict__["location"] == {"id": "rack-uid", "source": SOURCE_ID, "owner": OWNER_ID} + + +def test_update_node_relationship_one_no_attribution_when_unset(patch_resolve_peer: None) -> None: # noqa: ARG001 + rel = FakeRelSchema(name="location", peer="LocationRack", cardinality="one") + schema = FakeSchema(relationships=[rel], relationship_names=["location"]) + node = FakeNode(schema=schema, client=FakeClient(peers={"LocationRack": object()})) + + _run_update(node, {"location": "rack-uid"}) + + assert node.__dict__["location"] == {"id": "rack-uid"} + + +# --------------------------------------------------------------------------- +# update_node — cardinality-many relationship (the fix) +# --------------------------------------------------------------------------- + + +def test_update_node_relationship_many_add_gets_attribution(patch_resolve_peer: None) -> None: # noqa: ARG001 + rel = FakeRelSchema(name="tags", peer="BuiltinTag", cardinality="many") + schema = FakeSchema(relationships=[rel], relationship_names=["tags"]) + manager = FakeRelManager(existing_ids=["old-uid"]) + node = FakeNode( + schema=schema, + client=FakeClient(peers={"BuiltinTag": object()}), + many_managers={"tags": manager}, + ) + + _run_update(node, {"tags": ["t1-uid", "t2-uid"]}, source=SOURCE_ID, owner=OWNER_ID) + + # Stale peer removed; new peers added WITH attribution. + assert manager.removed == ["old-uid"] + assert manager.added == [ + {"id": "t1-uid", "source": SOURCE_ID, "owner": OWNER_ID}, + {"id": "t2-uid", "source": SOURCE_ID, "owner": OWNER_ID}, + ] + + +def test_update_node_relationship_many_no_attribution_when_unset(patch_resolve_peer: None) -> None: # noqa: ARG001 + rel = FakeRelSchema(name="tags", peer="BuiltinTag", cardinality="many") + schema = FakeSchema(relationships=[rel], relationship_names=["tags"]) + manager = FakeRelManager(existing_ids=[]) + node = FakeNode( + schema=schema, + client=FakeClient(peers={"BuiltinTag": object()}), + many_managers={"tags": manager}, + ) + + _run_update(node, {"tags": ["t1-uid"]}) + + assert manager.added == [{"id": "t1-uid"}]