From ed09d910059ab3e400d4e0016a5dbc9d76449689 Mon Sep 17 00:00:00 2001 From: DIDX Date: Tue, 28 Jul 2026 02:25:45 +0800 Subject: [PATCH] Reject empty handshake histories consistently --- passpod/handshake.py | 9 ++------ tests/test_cli.py | 36 +++++++++++++++++++++++++++++ tests/test_sdk_core.py | 19 ++++++++++----- tests/test_sdk_fixture_roundtrip.py | 5 ---- tests/test_semantic_validator.py | 10 +++++++- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/passpod/handshake.py b/passpod/handshake.py index 5ef745a..80ceb73 100644 --- a/passpod/handshake.py +++ b/passpod/handshake.py @@ -51,8 +51,7 @@ def __init__( object.__setattr__(self, "_data", data) self._set_history() - if self._data.get("messages"): - self.validate() + self.validate() def __repr__(self): return ( @@ -68,8 +67,7 @@ def from_mapping(cls, mapping): instance = cls.__new__(cls) object.__setattr__(instance, "_data", deepcopy(dict(mapping))) instance._set_history() - if instance._data.get("messages"): - instance.validate() + instance.validate() return instance def _set_history(self): @@ -134,9 +132,6 @@ def to_mapping(self): return deepcopy(self._data) def validate(self): - if not self._data.get("messages"): - return {"valid": True, "errors": []} - result = validateHandshake(self.to_mapping()) if not result.get("valid"): raise PasspodValidationError("validateHandshake", result.get("errors", [])) diff --git a/tests/test_cli.py b/tests/test_cli.py index bfa04d7..7e0b041 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -103,6 +103,42 @@ def test_validate_json_semantic_failure_preserves_validator_errors(self): self.assertIn("PARENT_REQUIRED", [error["code"] for error in payload["errors"]]) assert_no_traceback(self, result) + def test_validate_rejects_empty_handshake(self): + with tempfile.TemporaryDirectory() as directory: + path = write_json( + directory, + "empty-handshake.json", + {"handshakeIdentity": "hs-empty-001", "messages": []}, + ) + + result = run_cli("validate", str(path), "--json") + + payload = json.loads(result.stderr) + self.assertEqual(1, result.returncode) + self.assertEqual("", result.stdout) + self.assertFalse(payload["valid"]) + self.assertEqual("handshake", payload["artifact_type"]) + self.assertIn("SCHEMA_INVALID", [error["code"] for error in payload["errors"]]) + assert_no_traceback(self, result) + + def test_validate_rejects_missing_messages(self): + with tempfile.TemporaryDirectory() as directory: + path = write_json( + directory, + "missing-messages.json", + {"handshakeIdentity": "hs-missing-001"}, + ) + + result = run_cli("validate", str(path), "--json") + + payload = json.loads(result.stderr) + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout) + self.assertFalse(payload["valid"]) + self.assertIsNone(payload["artifact_type"]) + self.assertEqual("ARTIFACT_TYPE_UNKNOWN", payload["errors"][0]["code"]) + assert_no_traceback(self, result) + class InputFailureTests(unittest.TestCase): def test_detection_and_input_failures_are_exit_code_2(self): diff --git a/tests/test_sdk_core.py b/tests/test_sdk_core.py index 35e604a..e30a2c5 100644 --- a/tests/test_sdk_core.py +++ b/tests/test_sdk_core.py @@ -96,13 +96,20 @@ def msg(self, identity, message_type, parent=None, handshake_identity="hs-1"): data["parentReference"] = parent return Message.from_mapping(data) - def test_creation_with_handshake_identity(self): - handshake = Handshake("hs-1") + def test_empty_or_missing_messages_are_rejected(self): + cases = ( + lambda: Handshake("hs-1"), + lambda: Handshake.from_mapping({"handshakeIdentity": "hs-1", "messages": []}), + lambda: Handshake.from_mapping({"handshakeIdentity": "hs-1"}), + ) - self.assertEqual("hs-1", handshake.handshake_identity) - self.assertEqual("not_started", handshake.lifecycle) - self.assertFalse(handshake.is_closed) - self.assertEqual(0, len(handshake.history)) + for construct in cases: + with self.subTest(construct=construct): + with self.assertRaises(PasspodValidationError) as raised: + construct() + + self.assertEqual("validateHandshake", raised.exception.operation) + self.assertIn("SCHEMA_INVALID", error_codes(raised.exception)) def test_creation_from_minimal_valid_propose(self): handshake = Handshake("hs-1", messages=[self.msg("msg-1", "PROPOSE")]) diff --git a/tests/test_sdk_fixture_roundtrip.py b/tests/test_sdk_fixture_roundtrip.py index f040ba8..e0cafbb 100644 --- a/tests/test_sdk_fixture_roundtrip.py +++ b/tests/test_sdk_fixture_roundtrip.py @@ -97,11 +97,6 @@ def test_reprs_are_readable_and_bounded(self): self.assertNotIn("largePayload", repr(message)) def test_handshake_read_only_conveniences(self): - empty = Handshake("hs-empty-001") - self.assertEqual(0, empty.message_count) - self.assertIsNone(empty.last_message) - self.assertIsNone(empty.get_message("missing")) - handshake = Handshake.from_mapping(load_json(ROOT / "examples" / "valid" / "complete-handshake.json")) last = handshake.last_message diff --git a/tests/test_semantic_validator.py b/tests/test_semantic_validator.py index 06b4b39..ef4a71d 100644 --- a/tests/test_semantic_validator.py +++ b/tests/test_semantic_validator.py @@ -78,6 +78,15 @@ def assert_has_code(self, result, code): self.assertFalse(result["valid"]) self.assertIn(code, error_codes(result)) + def test_empty_or_missing_messages(self): + for handshake in ( + {"handshakeIdentity": "hs-direct-001", "messages": []}, + {"handshakeIdentity": "hs-direct-001"}, + ): + with self.subTest(handshake=handshake): + result = semantic_validator.validateHandshake(handshake) + self.assert_has_code(result, semantic_validator.SCHEMA_INVALID) + def test_duplicate_message_identity(self): result = semantic_validator.validateHandshake( self.handshake( @@ -218,4 +227,3 @@ def test_profile_attempt_to_redefine_canonical_message_type(self): if __name__ == "__main__": unittest.main() -