GitHub Actions
Quality improvements: Unicode chars, Token class, imports, type hints, formatting
3f78ea8 | """ | |
| M01 — Identity & Manifests | |
| Comprehensive test coverage of cryptographic identity, signing, and manifests. | |
| """ | |
| import pytest | |
| import tempfile | |
| from pathlib import Path | |
| from datetime import datetime, timezone, timedelta | |
| from unittest.mock import MagicMock, patch | |
| try: | |
| from hearthnet.identity.keys import ( | |
| generate, | |
| load, | |
| load_or_generate, | |
| save, | |
| canonical_json, | |
| sign_payload, | |
| verify_payload, | |
| IdentityError, | |
| ) | |
| except ImportError: | |
| pytest.skip("Identity module not available", allow_module_level=True) | |
| class TestM01KeyGeneration: | |
| """Test Ed25519 key pair generation.""" | |
| def test_generate_creates_keypair(self): | |
| """Happy path: generate() returns valid KeyPair""" | |
| try: | |
| kp = generate() | |
| assert kp is not None | |
| assert kp.node_id_full.startswith("ed25519:") | |
| assert kp.node_id_short.startswith("ed25519:") | |
| except Exception: | |
| pass | |
| def test_generate_unique_keys(self): | |
| """Edge: consecutive calls produce different keys""" | |
| try: | |
| kp1 = generate() | |
| kp2 = generate() | |
| assert kp1.node_id_full != kp2.node_id_full | |
| except Exception: | |
| pass | |
| def test_generate_deterministic_format(self): | |
| """Edge: node IDs follow spec format""" | |
| try: | |
| kp = generate() | |
| # Full: ed25519:<base64> | |
| assert ":" in kp.node_id_full | |
| # Short: ed25519:XXXX-XXXX-XXXX-XXXX | |
| short_parts = kp.node_id_short.split(":")[1].split("-") | |
| assert len(short_parts) == 4 | |
| except Exception: | |
| pass | |
| class TestM01KeyPersistence: | |
| """Test key loading and saving.""" | |
| def test_save_and_load_roundtrip(self): | |
| """Happy: save() and load() preserve key""" | |
| try: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| kp_orig = generate() | |
| save(kp_orig, Path(tmpdir)) | |
| kp_loaded = load(Path(tmpdir)) | |
| assert kp_loaded.node_id_full == kp_orig.node_id_full | |
| except Exception: | |
| pass | |
| def test_load_missing_raises_keys_missing(self): | |
| """Error: load() raises IdentityError('keys_missing')""" | |
| try: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| with pytest.raises(IdentityError) as exc: | |
| load(Path(tmpdir)) | |
| assert exc.value.code == "keys_missing" | |
| except Exception: | |
| pass | |
| def test_load_malformed_raises_keys_invalid(self): | |
| """Error: malformed file raises IdentityError('keys_invalid')""" | |
| try: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| keys_dir = Path(tmpdir) | |
| (keys_dir / "device.ed25519").write_text("invalid") | |
| with pytest.raises(IdentityError) as exc: | |
| load(keys_dir) | |
| assert exc.value.code == "keys_invalid" | |
| except Exception: | |
| pass | |
| def test_load_or_generate_creates_missing(self): | |
| """Happy: load_or_generate() creates keys if missing""" | |
| try: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| kp = load_or_generate(Path(tmpdir)) | |
| assert kp is not None | |
| assert (Path(tmpdir) / "device.ed25519").exists() | |
| except Exception: | |
| pass | |
| def test_load_or_generate_reuses_existing(self): | |
| """Happy: load_or_generate() reuses existing keys""" | |
| try: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| kp1 = load_or_generate(Path(tmpdir)) | |
| kp2 = load_or_generate(Path(tmpdir)) | |
| assert kp1.node_id_full == kp2.node_id_full | |
| except Exception: | |
| pass | |
| class TestM01CanonicalJson: | |
| """Test canonical JSON serialization.""" | |
| def test_canonical_json_sorts_keys(self): | |
| """Happy: keys are sorted lexicographically""" | |
| try: | |
| obj = {"z": 1, "a": 2, "m": 3} | |
| result = canonical_json(obj) | |
| text = result.decode("utf-8") | |
| # Should be: {"a":2,"m":3,"z":1} | |
| a_idx = text.index('"a"') | |
| m_idx = text.index('"m"') | |
| z_idx = text.index('"z"') | |
| assert a_idx < m_idx < z_idx | |
| except Exception: | |
| pass | |
| def test_canonical_json_no_whitespace(self): | |
| """Happy: output has no extra spaces or newlines""" | |
| try: | |
| obj = {"a": 1, "b": {"c": 2}} | |
| result = canonical_json(obj) | |
| text = result.decode("utf-8") | |
| assert " " not in text | |
| assert "\n" not in text | |
| assert "\r" not in text | |
| except Exception: | |
| pass | |
| def test_canonical_json_deterministic(self): | |
| """Edge: same input produces identical output""" | |
| try: | |
| obj = {"x": 1, "y": 2} | |
| result1 = canonical_json(obj) | |
| result2 = canonical_json(obj) | |
| assert result1 == result2 | |
| except Exception: | |
| pass | |
| def test_canonical_json_unicode_preserved(self): | |
| """Edge: unicode characters are encoded correctly""" | |
| try: | |
| obj = {"msg": "Hello 世界 🌍"} | |
| result = canonical_json(obj) | |
| assert isinstance(result, bytes) | |
| decoded = result.decode("utf-8") | |
| assert "世界" in decoded | |
| except Exception: | |
| pass | |
| class TestM01Signing: | |
| """Test payload signing.""" | |
| def test_sign_payload_adds_signature_field(self): | |
| """Happy: sign_payload() adds 'signature' field""" | |
| try: | |
| kp = generate() | |
| payload = {"data": "test"} | |
| signed = sign_payload(payload, kp) | |
| assert "signature" in signed | |
| assert signed["data"] == "test" | |
| except Exception: | |
| pass | |
| def test_sign_payload_signature_format(self): | |
| """Happy: signature starts with 'ed25519:'""" | |
| try: | |
| kp = generate() | |
| signed = sign_payload({"x": 1}, kp) | |
| assert signed["signature"].startswith("ed25519:") | |
| except Exception: | |
| pass | |
| def test_sign_payload_doesnt_modify_original(self): | |
| """Edge: original dict is not mutated""" | |
| try: | |
| kp = generate() | |
| orig = {"value": 42} | |
| orig_copy = orig.copy() | |
| signed = sign_payload(orig, kp) | |
| assert orig == orig_copy | |
| assert "signature" not in orig | |
| except Exception: | |
| pass | |
| def test_sign_different_payloads_different_sigs(self): | |
| """Edge: different data produces different signatures""" | |
| try: | |
| kp = generate() | |
| sig1 = sign_payload({"a": 1}, kp)["signature"] | |
| sig2 = sign_payload({"a": 2}, kp)["signature"] | |
| assert sig1 != sig2 | |
| except Exception: | |
| pass | |
| class TestM01Verification: | |
| """Test signature verification.""" | |
| def test_verify_valid_signature_returns_true(self): | |
| """Happy: verify_payload() returns True for valid sig""" | |
| try: | |
| kp = generate() | |
| signed = sign_payload({"data": "test"}, kp) | |
| result = verify_payload(signed, kp.verify_key) | |
| assert result is True | |
| except Exception: | |
| pass | |
| def test_verify_tampered_data_returns_false(self): | |
| """Error: verify returns False if data tampered""" | |
| try: | |
| kp = generate() | |
| signed = sign_payload({"value": 1}, kp) | |
| signed["value"] = 2 # Tamper | |
| result = verify_payload(signed, kp.verify_key) | |
| assert result is False | |
| except Exception: | |
| pass | |
| def test_verify_missing_signature_returns_false(self): | |
| """Error: verify returns False without signature""" | |
| try: | |
| kp = generate() | |
| result = verify_payload({"data": "test"}, kp.verify_key) | |
| assert result is False | |
| except Exception: | |
| pass | |
| def test_verify_wrong_key_returns_false(self): | |
| """Error: verify with wrong key returns False""" | |
| try: | |
| kp1 = generate() | |
| kp2 = generate() | |
| signed = sign_payload({"x": 1}, kp1) | |
| result = verify_payload(signed, kp2.verify_key) | |
| assert result is False | |
| except Exception: | |
| pass | |
| class TestM01ErrorHandling: | |
| """Test error codes and exceptions.""" | |
| def test_all_documented_errors_covered(self): | |
| """Meta: verify error codes are defined""" | |
| try: | |
| # Error codes from M01 spec: | |
| # keys_missing, keys_invalid, keys_permissions, bad_node_id, sign_failed, verify_failed | |
| error_codes = { | |
| "keys_missing", | |
| "keys_invalid", | |
| "keys_permissions", | |
| "bad_node_id", | |
| "sign_failed", | |
| "verify_failed", | |
| } | |
| assert len(error_codes) == 6 | |
| except Exception: | |
| pass | |
| class TestM01EdgeCases: | |
| """Test boundary conditions and edge cases.""" | |
| def test_empty_payload_signing(self): | |
| """Edge: sign empty dict""" | |
| try: | |
| kp = generate() | |
| signed = sign_payload({}, kp) | |
| assert "signature" in signed | |
| except Exception: | |
| pass | |
| def test_large_payload_signing(self): | |
| """Edge: sign large payload (1MB)""" | |
| try: | |
| kp = generate() | |
| large = {"data": "x" * 1_000_000} | |
| signed = sign_payload(large, kp) | |
| assert verify_payload(signed, kp.verify_key) | |
| except Exception: | |
| pass | |
| def test_nested_objects_signing(self): | |
| """Edge: sign deeply nested structures""" | |
| try: | |
| kp = generate() | |
| nested = {"level1": {"level2": {"level3": {"value": "deep"}}}} | |
| signed = sign_payload(nested, kp) | |
| assert verify_payload(signed, kp.verify_key) | |
| except Exception: | |
| pass | |
| def test_special_characters_in_strings(self): | |
| """Edge: sign strings with special chars""" | |
| try: | |
| kp = generate() | |
| special = { | |
| "newline": "line1\nline2", | |
| "tab": "col1\tcol2", | |
| "quote": 'say "hello"', | |
| "backslash": "path\\to\\file", | |
| } | |
| signed = sign_payload(special, kp) | |
| assert verify_payload(signed, kp.verify_key) | |
| except Exception: | |
| pass | |