Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions infrahub_sync/adapters/infrahub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
277 changes: 277 additions & 0 deletions tests/adapters/test_infrahub_update_node_attribution.py
Original file line number Diff line number Diff line change
@@ -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"}]
Loading