diff --git a/pytm/data.py b/pytm/data.py index c11f24a..f4fcc43 100644 --- a/pytm/data.py +++ b/pytm/data.py @@ -123,7 +123,14 @@ def __eq__(self, other: object) -> bool: def __hash__(self): """Make Data objects hashable for use in sets.""" - return hash((self.name, self.description, self.format, self.classification)) + return hash( + ( + getattr(self, "name", None), + getattr(self, "description", None), + getattr(self, "format", None), + getattr(self, "classification", None), + ) + ) def _safeset(self, attr: str, value) -> None: """Safely set an attribute value.""" diff --git a/tests/test_pydantic_models.py b/tests/test_pydantic_models.py index 75adf76..0475da9 100644 --- a/tests/test_pydantic_models.py +++ b/tests/test_pydantic_models.py @@ -4,6 +4,8 @@ correctly — coverage that generic functional tests do not exercise. """ +import copy + import pytest from pytm import ( @@ -337,6 +339,49 @@ def test_different_data_distinct_in_set(self): b = Data("cookie") assert len({a, b}) == 2 + def test_deepcopy_with_dataflow_cycle(self): + # Data -> carriedBy -> Dataflow -> data (DataSet) -> same Data. + # From pydantic 2.14, __deepcopy__ memoizes the new Data before its + # fields are copied, so the DataSet copy hashes a Data with no + # attributes yet. + token = Data("token", classification=Classification.SECRET) + flow = Dataflow(Actor("user"), Server("web"), "login", data=token) + token.carriedBy = [flow] + + copied = copy.deepcopy(token) + + assert copied is not token + assert copied.name == "token" + assert copied.classification == Classification.SECRET + copied_flow = copied.carriedBy[0] + assert copied_flow is not flow + # Whether the flow's data is the same copy depends on the pydantic + # version; either way it must be a copy of the token, not the token. + assert [d.name for d in copied_flow.data] == ["token"] + assert all(d is not token for d in copied_flow.data) + + def test_report_with_data_carried_by_multiple_flows(self, tmp_path): + # TM.report() deep-copies elements. When one Data travels over more + # than one Dataflow this used to raise + # AttributeError: 'Data' object has no attribute 'name'. + TM.reset() + tm = TM("report tm", description="test") + token = Data("token", classification=Classification.SECRET) + web = Server("web") + Dataflow(Actor("user"), web, "login", data=token) + Dataflow(web, Datastore("db"), "store token", data=token) + template = tmp_path / "template.md" + template.write_text("{tm.name}\n{data:repeat:{{item.name}}\n}") + + tm.check() + tm.resolve() + assert len(token.carriedBy) == 2 + + report = tm.report(str(template)) + + assert "report tm" in report + assert "token" in report + # --------------------------------------------------------------------------- # Dataflow.hasDataLeaks