"""Canonicalization tests — the four isolation properties that must stay green. These are not incidental unit tests. Each one protects a property the evidence model depends on; see ``docs/specs/EvidenceModel.md`` and the negative cases NC-05, NC-06, NC-10 in ``docs/specs/UseCaseCatalog.md``. If one of these fails, ``view_hash`` no longer means what ``INTENT.md`` claims it means, and the promise "this person was shown this view" is unsupported. """ from __future__ import annotations import copy import json import pathlib import random import pytest from informed_decision.canonicalize import awareness_hash, view_hash VECTORS = pathlib.Path(__file__).parent / "vectors" def load(name: str) -> dict: return json.loads((VECTORS / f"{name}.json").read_text(encoding="utf-8")) @pytest.fixture(scope="module") def expected() -> dict: return load("expected") @pytest.fixture def login_binding() -> dict: return load("login-binding") @pytest.fixture def login_awareness() -> dict: return load("login-awareness") @pytest.fixture def adr_binding() -> dict: return load("adr-binding") # -------------------------------------------------------------------------- # Published vectors — these hashes appear in InitialExploration.md §9 and are # quoted in the founding record. They must reproduce byte for byte. # -------------------------------------------------------------------------- def test_login_view_hash_matches_published_vector(login_binding, expected): assert view_hash(login_binding)["hex"] == expected["login_view_hash"] def test_login_awareness_hash_matches_published_vector(login_awareness, expected): assert awareness_hash(login_awareness)["hex"] == expected["login_awareness_hash"] def test_adr_view_hash_matches_published_vector(adr_binding, expected): assert view_hash(adr_binding)["hex"] == expected["adr_view_hash"] # -------------------------------------------------------------------------- # Isolation 1 — key order is not part of the hash. # Without this, every hash is an artifact of serialisation order and no # verifier written against a different JSON library agrees with us. NC-10. # -------------------------------------------------------------------------- def shuffled(value): """Recursively rebuild dicts with their keys in a different order.""" if isinstance(value, dict): items = [(k, shuffled(v)) for k, v in value.items()] rng = random.Random(1337) rng.shuffle(items) return dict(items) if isinstance(value, list): return [shuffled(v) for v in value] return value @pytest.mark.parametrize("name", ["login-binding", "adr-binding"]) def test_key_order_does_not_change_view_hash(name): doc = load(name) assert view_hash(shuffled(doc))["hex"] == view_hash(doc)["hex"] def test_key_order_does_not_change_awareness_hash(login_awareness): assert ( awareness_hash(shuffled(login_awareness))["hex"] == awareness_hash(login_awareness)["hex"] ) # -------------------------------------------------------------------------- # Isolation 2 — awareness never enters view_hash. # This is the property that lets the surface default a role to last-used for # situational awareness without silently signing it. NC-05, PR-40. # -------------------------------------------------------------------------- def test_editing_awareness_does_not_change_view_hash(login_binding): before = view_hash(login_binding)["hex"] mutated = copy.deepcopy(login_binding) mutated["awareness"] = { "proposed_hat": {"id": "hat:auditor", "label": "Auditor"}, "proposed_hat_source": "last_used", "situation_note": "changed after the presentation was taken", } mutated["proposed_hat_source"] = "policy" assert view_hash(mutated)["hex"] == before def test_unknown_top_level_keys_are_stripped_from_view_hash(login_binding): before = view_hash(login_binding)["hex"] mutated = copy.deepcopy(login_binding) mutated["not_in_the_allow_list"] = {"anything": "at all"} assert view_hash(mutated)["hex"] == before # -------------------------------------------------------------------------- # Isolation 3 — the binding slice IS covered. # The positive control for isolation 2: if this passed while 2 also passed # vacuously, view_hash would be covering nothing. NC-06. # -------------------------------------------------------------------------- def test_changing_binding_target_changes_view_hash(login_binding): before = view_hash(login_binding)["hex"] mutated = copy.deepcopy(login_binding) target = mutated["binding"]["target"] assert target, "vector must carry a binding target for this test to mean anything" if isinstance(target, dict): key = "id" if "id" in target else next(iter(target)) target[key] = f"{target[key]}-BETA" else: mutated["binding"]["target"] = f"{target}-BETA" assert view_hash(mutated)["hex"] != before @pytest.mark.parametrize("field", ["question", "requested_act", "binding_level"]) def test_changing_a_binding_field_changes_view_hash(adr_binding, field): before = view_hash(adr_binding)["hex"] mutated = copy.deepcopy(adr_binding) if field not in mutated: pytest.skip(f"vector does not carry {field}") mutated[field] = f"{mutated[field]}-changed" assert view_hash(mutated)["hex"] != before def test_changing_the_packet_changes_view_hash(adr_binding): before = view_hash(adr_binding)["hex"] mutated = copy.deepcopy(adr_binding) packet = mutated.get("packet") if not packet: pytest.skip("vector carries no packet") packet[0]["hash"] = "sha256:" + "0" * 64 assert view_hash(mutated)["hex"] != before # -------------------------------------------------------------------------- # Isolation 4 — selecting a role after login does not rewrite view_hash. # Post-bind session state is a different object from the signed binding. # NC-04, and the reason `configure` exists as a verb at all. # -------------------------------------------------------------------------- def test_post_bind_hat_selection_does_not_change_view_hash(login_binding): before = view_hash(login_binding)["hex"] mutated = copy.deepcopy(login_binding) mutated["session"] = { "status": "active", "hat": {"id": "hat:finance-controller", "elevates": False}, "events": [{"kind": "session.hat_selected", "at": "2026-09-09T10:00:00Z"}], } assert view_hash(mutated)["hex"] == before # -------------------------------------------------------------------------- # Canonical form properties. # -------------------------------------------------------------------------- @pytest.mark.parametrize("name", ["login-binding", "adr-binding"]) def test_canonical_form_has_no_insignificant_whitespace(name): """No whitespace between structural tokens. Checked by round-trip rather than substring search: ", " and ": " occur legitimately inside string *values* (a brief is prose), so a naive scan reports a defect that is not there. """ canonical = view_hash(load(name))["canonical"] reserialized = json.dumps( json.loads(canonical), separators=(",", ":"), sort_keys=True, ensure_ascii=False, ) assert canonical == reserialized def test_canonical_form_keys_are_sorted(login_binding): canonical = view_hash(login_binding)["canonical"] keys = list(json.loads(canonical).keys()) assert keys == sorted(keys) def test_canonical_form_is_utf8_encodable_and_hash_is_over_utf8(login_binding): import hashlib result = view_hash(login_binding) assert ( hashlib.sha256(result["canonical"].encode("utf-8")).hexdigest() == result["hex"] ) def test_hash_is_stable_across_repeated_calls(login_binding): assert view_hash(login_binding)["hex"] == view_hash(login_binding)["hex"] # -------------------------------------------------------------------------- # Provenance — the governed copy must not drift from the founding record. # -------------------------------------------------------------------------- def test_governed_vectors_match_the_preserved_history_copy(): history = ( pathlib.Path(__file__).resolve().parents[1] / "history" / "20260909-initial-exploration" / "vectors" ) if not history.is_dir(): pytest.skip("history/ not present in this checkout") for governed in sorted(VECTORS.glob("*.json")): original = history / governed.name assert original.is_file(), f"{governed.name} has no provenance original" assert json.loads(governed.read_text()) == json.loads(original.read_text()), ( f"{governed.name} drifted from the preserved founding copy" )