test_phase3_wp08_third_domain_replication.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. from __future__ import annotations
  2. import csv
  3. import hashlib
  4. import importlib.util
  5. import json
  6. import os
  7. import shutil
  8. from copy import deepcopy
  9. from pathlib import Path
  10. import pytest
  11. import app.core.governance.domain_replication_contract_registry as contract_registry
  12. from app.core.governance.domain_replication import (
  13. REQUIRED_STAGES,
  14. DomainReplicationError,
  15. validate_contract_reference,
  16. validate_execution_evidence,
  17. )
  18. from app.core.governance.domain_replication_contract_registry import registry_snapshot
  19. ROOT = Path(__file__).resolve().parents[2]
  20. PACKAGE_DIR = ROOT / "docs/phase3/p3-wp08-third-domain-template"
  21. P2_PACKAGE_DIR = ROOT / "docs/phase2/p2-wp12-spare-parts-package"
  22. VALIDATOR_PATH = ROOT / "scripts/validate_domain_replication.py"
  23. VALIDATOR_SPEC = importlib.util.spec_from_file_location("wp08_validator", VALIDATOR_PATH)
  24. assert VALIDATOR_SPEC and VALIDATOR_SPEC.loader
  25. validator = importlib.util.module_from_spec(VALIDATOR_SPEC)
  26. VALIDATOR_SPEC.loader.exec_module(validator)
  27. def _package_json(name: str) -> dict:
  28. return json.loads((PACKAGE_DIR / name).read_text(encoding="utf-8"))
  29. def _package_rows(name: str) -> list[dict[str, str]]:
  30. with (PACKAGE_DIR / "data" / name).open(encoding="utf-8", newline="") as handle:
  31. return list(csv.DictReader(handle))
  32. def _package_copy(tmp_path: Path) -> Path:
  33. package = tmp_path / "package"
  34. shutil.copytree(PACKAGE_DIR, package)
  35. return package
  36. def _execution_evidence() -> dict:
  37. return {
  38. "schema_version": 1,
  39. "stages": [
  40. {
  41. "stage": stage,
  42. "actions": [
  43. {
  44. "action": "dry_run",
  45. "status": "passed",
  46. "write_count": 0,
  47. "evidence_ref": f"dry-run://third_domain_template/{stage}",
  48. "audit_ref": f"audit://third_domain_template/{stage}/dry-run",
  49. },
  50. {
  51. "action": "execute",
  52. "status": "passed",
  53. "write_count": 1,
  54. "evidence_ref": f"execution://third_domain_template/{stage}",
  55. "audit_ref": f"audit://third_domain_template/{stage}/execute",
  56. },
  57. {
  58. "action": "diff",
  59. "status": "passed",
  60. "write_count": 0,
  61. "evidence_ref": f"diff://third_domain_template/{stage}",
  62. "audit_ref": f"audit://third_domain_template/{stage}/diff",
  63. },
  64. ],
  65. }
  66. for stage in REQUIRED_STAGES
  67. ],
  68. }
  69. def _strict_execution_evidence() -> dict:
  70. """V2 execution receipts bind rollback to its executed state transition."""
  71. evidence = _execution_evidence()
  72. evidence["schema_version"] = 2
  73. replay_receipts: list[dict[str, object]] = []
  74. for index, receipt in enumerate(evidence["stages"]):
  75. stage = receipt["stage"]
  76. execute = receipt["actions"][1]
  77. execute.update(
  78. {
  79. "operation_uid": f"execute-{index:02d}",
  80. "idempotency_key": f"execute-{index:02d}",
  81. "request_digest": f"{index + 1:064x}",
  82. "attempt": 1,
  83. "lease_fence": index + 1,
  84. "pre_state_digest": f"{index + 101:064x}",
  85. "post_state_digest": f"{index + 201:064x}",
  86. "result_digest": f"{index + 301:064x}",
  87. }
  88. )
  89. if stage == "remediation":
  90. rollback = receipt["actions"][2]
  91. rollback.update(
  92. {
  93. "action": "rollback",
  94. "operation": "restore",
  95. "operation_uid": execute["operation_uid"],
  96. "idempotency_key": execute["idempotency_key"],
  97. "request_digest": execute["request_digest"],
  98. "attempt": execute["attempt"],
  99. "lease_fence": execute["lease_fence"],
  100. "from_digest": execute["post_state_digest"],
  101. "to_digest": execute["pre_state_digest"],
  102. "result_digest": f"{index + 401:064x}",
  103. "status": "passed",
  104. }
  105. )
  106. replay_receipts.append(
  107. {
  108. "stage": stage,
  109. "operation_uid": execute["operation_uid"],
  110. "idempotency_key": execute["idempotency_key"],
  111. "request_digest": execute["request_digest"],
  112. "attempt": execute["attempt"],
  113. "lease_fence": execute["lease_fence"],
  114. "from_digest": execute["post_state_digest"],
  115. "to_digest": execute["pre_state_digest"],
  116. "result_digest": rollback["result_digest"],
  117. "status": "exact_replay",
  118. }
  119. )
  120. evidence["replay_receipts"] = replay_receipts
  121. return evidence
  122. def test_v2_rollback_requires_matching_execute_state_and_independent_replay_receipt():
  123. execution = _strict_execution_evidence()
  124. assert validate_execution_evidence(execution)["remediation"]["recovery_strategy"] == "rollback"
  125. delayed = deepcopy(execution)
  126. delayed["stages"][6]["actions"][2]["lease_fence"] = 6
  127. with pytest.raises(DomainReplicationError, match="lease_fence"):
  128. validate_execution_evidence(delayed)
  129. changed = deepcopy(execution)
  130. changed["replay_receipts"][0]["to_digest"] = "a" * 64
  131. with pytest.raises(DomainReplicationError, match="replay receipt"):
  132. validate_execution_evidence(changed)
  133. forged = deepcopy(execution)
  134. forged["stages"][6]["actions"][2]["from_digest"] = "b" * 64
  135. with pytest.raises(DomainReplicationError, match="execute post_state_digest"):
  136. validate_execution_evidence(forged)
  137. malformed = deepcopy(execution)
  138. malformed["stages"][6]["actions"][2]["result_digest"] = "not-a-digest"
  139. malformed["replay_receipts"][0]["result_digest"] = "not-a-digest"
  140. with pytest.raises(DomainReplicationError, match="remediation.rollback.result_digest"):
  141. validate_execution_evidence(malformed)
  142. def test_execution_evidence_requires_ordered_dry_run_execute_and_diff():
  143. summary = validate_execution_evidence(_execution_evidence())
  144. assert list(summary) == list(REQUIRED_STAGES)
  145. assert all(item["actions"] == ["dry_run", "execute", "diff"] for item in summary.values())
  146. assert all(item["dry_run_write_count"] == 0 for item in summary.values())
  147. assert all(item["recovery_strategy"] == "diff" for item in summary.values())
  148. assert all(len(item["evidence_digest"]) == 64 for item in summary.values())
  149. @pytest.mark.parametrize(
  150. ("mutate", "message"),
  151. [
  152. (
  153. lambda evidence: evidence["stages"][0]["actions"].__setitem__(
  154. 0, evidence["stages"][0]["actions"][1]
  155. ),
  156. "ordered",
  157. ),
  158. (
  159. lambda evidence: evidence["stages"][0]["actions"][0].update(
  160. {"write_count": 1}
  161. ),
  162. "dry_run",
  163. ),
  164. (
  165. lambda evidence: evidence["stages"][0]["actions"][2].update(
  166. {"action": "verify"}
  167. ),
  168. "diff or rollback",
  169. ),
  170. (
  171. lambda evidence: evidence["stages"][0]["actions"][1].update(
  172. {"source_url": "https://enterprise.invalid/source"}
  173. ),
  174. "URL",
  175. ),
  176. (
  177. lambda evidence: evidence["stages"][0]["actions"][1].update(
  178. {"raw_rows": [{"id": "sample"}]}
  179. ),
  180. "raw source rows",
  181. ),
  182. ],
  183. )
  184. def test_execution_evidence_fails_closed_for_unsafe_or_incomplete_receipts(
  185. mutate, message: str
  186. ):
  187. evidence = _execution_evidence()
  188. mutate(evidence)
  189. with pytest.raises(DomainReplicationError, match=message):
  190. validate_execution_evidence(evidence)
  191. def test_execution_evidence_rejects_cross_domain_receipt_reference():
  192. evidence = _execution_evidence()
  193. evidence["stages"][1]["actions"][1]["evidence_ref"] = (
  194. "execution://other_domain/catalog"
  195. )
  196. with pytest.raises(DomainReplicationError, match="cross-domain"):
  197. validate_execution_evidence(evidence)
  198. def test_execution_evidence_is_deterministic_across_a_replay():
  199. evidence = _execution_evidence()
  200. assert validate_execution_evidence(evidence) == validate_execution_evidence(
  201. deepcopy(evidence)
  202. )
  203. def test_third_domain_template_is_hash_bound_and_uat_blocked():
  204. manifest = _package_json("manifest.json")
  205. evidence = _package_json("evidence.json")
  206. execution_evidence = _package_json("execution-evidence.json")
  207. report = __import__(
  208. "app.core.governance.domain_replication", fromlist=["evaluate_replication_package"]
  209. ).evaluate_replication_package(
  210. manifest,
  211. evidence,
  212. template=_package_json("domain-template.json"),
  213. snapshot_rows=_package_rows("governance_object_snapshot.csv"),
  214. delta_rows=_package_rows("governance_object_delta.csv"),
  215. execution_evidence=execution_evidence,
  216. )
  217. assert report["schema_version"] == 2
  218. assert report["status"] == "passed"
  219. assert report["enterprise_uat"] == {
  220. "status": "blocked_external",
  221. "unbound_requirements": [
  222. "data_steward",
  223. "domain_owner",
  224. "readonly_source",
  225. "uat_users",
  226. ],
  227. }
  228. assert set(report["execution_evidence"]) == set(REQUIRED_STAGES)
  229. assert report["device_specific_core_changes"] == 0
  230. assert "spare_parts" not in json.dumps(report, ensure_ascii=False)
  231. assert "/device-" not in json.dumps(report, ensure_ascii=False)
  232. assert "sample-001" not in json.dumps(report, ensure_ascii=False)
  233. for binding in manifest["files"].values():
  234. path = PACKAGE_DIR / binding["path"]
  235. assert path.is_file()
  236. assert hashlib.sha256(path.read_bytes()).hexdigest() == binding["sha256"]
  237. assert json.loads((PACKAGE_DIR / "acceptance-report.json").read_text(encoding="utf-8")) == report
  238. @pytest.mark.parametrize("path_name", ["governance_object_snapshot.csv", "manifest.json"])
  239. def test_package_reader_rejects_hardlinked_inputs(tmp_path: Path, path_name: str):
  240. package = _package_copy(tmp_path)
  241. target = next(path for path in package.rglob(path_name))
  242. os.link(target, package / f"second-{path_name}")
  243. with pytest.raises(DomainReplicationError, match="hardlink"):
  244. validator.validate_package(package)
  245. def test_package_reader_rejects_symlinked_input(tmp_path: Path):
  246. package = _package_copy(tmp_path)
  247. snapshot = package / "data/governance_object_snapshot.csv"
  248. replacement = package / "snapshot-safe-copy.csv"
  249. snapshot.rename(replacement)
  250. snapshot.symlink_to(replacement.name)
  251. with pytest.raises(DomainReplicationError, match="symlink"):
  252. validator.validate_package(package)
  253. def test_package_reader_fails_closed_when_open_target_is_replaced(
  254. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  255. ):
  256. package = _package_copy(tmp_path)
  257. original_open = os.open
  258. swapped = False
  259. def race_open(path, flags, *args, **kwargs):
  260. nonlocal swapped
  261. if path == "governance_object_snapshot.csv" and not swapped:
  262. swapped = True
  263. snapshot = package / "data/governance_object_snapshot.csv"
  264. replacement = package / "replacement.csv"
  265. snapshot.rename(replacement)
  266. snapshot.symlink_to(replacement.name)
  267. return original_open(path, flags, *args, **kwargs)
  268. monkeypatch.setattr(validator.os, "open", race_open)
  269. with pytest.raises(DomainReplicationError, match="symlink|changed"):
  270. validator.validate_package(package)
  271. @pytest.mark.parametrize(
  272. ("target", "key", "value", "message"),
  273. [
  274. ("manifest", "unknown_field", True, "unsupported fields"),
  275. ("manifest", "tоken", "x", "sensitive"),
  276. ("manifest", "rаw_rows", [{"id": "sample"}], "raw source"),
  277. ("template", "records", [{"id": "sample"}], "unsupported fields"),
  278. ("evidence", "raw_rows", [{"id": "sample"}], "raw source"),
  279. ("evidence", "source_url", "https://enterprise.invalid", "URL"),
  280. ],
  281. )
  282. def test_closed_package_schemas_reject_unknown_confusable_and_unsafe_values(
  283. target: str, key: str, value: object, message: str
  284. ):
  285. manifest = _package_json("manifest.json")
  286. evidence = _package_json("evidence.json")
  287. template = _package_json("domain-template.json")
  288. {"manifest": manifest, "evidence": evidence, "template": template}[target][key] = value
  289. with pytest.raises(DomainReplicationError, match=message):
  290. __import__(
  291. "app.core.governance.domain_replication", fromlist=["evaluate_replication_package"]
  292. ).evaluate_replication_package(
  293. manifest,
  294. evidence,
  295. template=template,
  296. snapshot_rows=_package_rows("governance_object_snapshot.csv"),
  297. delta_rows=_package_rows("governance_object_delta.csv"),
  298. execution_evidence=_package_json("execution-evidence.json"),
  299. )
  300. def test_terminal_evidence_requires_allowlisted_api_and_typed_connector_contract():
  301. evidence = _package_json("evidence.json")
  302. incremental = next(item for item in evidence["receipts"] if item["stage"] == "incremental_ingestion")
  303. incremental["api_refs"] = ["not-a-contract"]
  304. with pytest.raises(DomainReplicationError, match="allowlisted API"):
  305. _accept_third_domain(evidence=evidence)
  306. evidence = _package_json("evidence.json")
  307. incremental = next(item for item in evidence["receipts"] if item["stage"] == "incremental_ingestion")
  308. incremental["contract_refs"][0]["run_id"] = "not-a-contract"
  309. with pytest.raises(DomainReplicationError, match="trusted registry"):
  310. _accept_third_domain(evidence=evidence)
  311. def test_closed_schema_rejects_terminal_unknown_field_and_depth_or_size_attack():
  312. evidence = _package_json("evidence.json")
  313. evidence["receipts"][0]["unexpected"] = True
  314. with pytest.raises(DomainReplicationError, match="unsupported fields"):
  315. _accept_third_domain(evidence=evidence)
  316. manifest = _package_json("manifest.json")
  317. nested: dict[str, object] = {"leaf": "safe"}
  318. for _ in range(13):
  319. nested = {"safe": nested}
  320. manifest["safe"] = nested
  321. with pytest.raises(DomainReplicationError, match="maximum package depth"):
  322. __import__(
  323. "app.core.governance.domain_replication", fromlist=["evaluate_replication_package"]
  324. ).evaluate_replication_package(
  325. manifest,
  326. _package_json("evidence.json"),
  327. template=_package_json("domain-template.json"),
  328. snapshot_rows=_package_rows("governance_object_snapshot.csv"),
  329. delta_rows=_package_rows("governance_object_delta.csv"),
  330. execution_evidence=_package_json("execution-evidence.json"),
  331. )
  332. evidence = _package_json("evidence.json")
  333. evidence["safe"] = "x" * 4097
  334. with pytest.raises(DomainReplicationError, match="maximum string length"):
  335. _accept_third_domain(evidence=evidence)
  336. def test_rollback_receipt_binds_replay_fence_and_digests():
  337. execution = _execution_evidence()
  338. rollback = execution["stages"][0]["actions"][2]
  339. rollback.update(
  340. {
  341. "action": "rollback",
  342. "operation": "restore",
  343. "idempotency_key": "rollback-001",
  344. "request_digest": "a" * 64,
  345. "attempt": 1,
  346. "lease_fence": 4,
  347. "from_digest": "b" * 64,
  348. "to_digest": "c" * 64,
  349. "replay_status": "exact_replay",
  350. }
  351. )
  352. assert validate_execution_evidence(execution)["template_initialization"]["recovery_strategy"] == "rollback"
  353. rollback["lease_fence"] = 0
  354. with pytest.raises(DomainReplicationError, match="lease_fence"):
  355. validate_execution_evidence(execution)
  356. rollback["lease_fence"] = 4
  357. rollback["replay_status"] = "changed_replay_conflict"
  358. with pytest.raises(DomainReplicationError, match="exact_replay"):
  359. validate_execution_evidence(execution)
  360. def test_v2_contract_reference_must_match_trusted_registry_run():
  361. registry = registry_snapshot()
  362. contract = registry["contracts"][0]
  363. run = contract["runs"][0]
  364. reference = {
  365. "contract_id": contract["contract_id"],
  366. "contract_version": contract["contract_version"],
  367. "schema_digest": contract["schema_digest"],
  368. **run,
  369. }
  370. assert validate_contract_reference(reference, expected_contract_id=contract["contract_id"]) == reference
  371. for key, value in (("run_id", "invented-run"), ("operation", "unknown"), ("schema_digest", "f" * 64)):
  372. forged = deepcopy(reference)
  373. forged[key] = value
  374. with pytest.raises(DomainReplicationError, match="registry|contract"):
  375. validate_contract_reference(forged, expected_contract_id=contract["contract_id"])
  376. def test_trusted_registry_rejects_tamper_expiry_and_contract_version_mismatch(monkeypatch):
  377. original_registry = registry_snapshot()
  378. original_digest = contract_registry.REGISTRY_SHA256
  379. contract = registry_snapshot()["contracts"][0]
  380. reference = {
  381. "contract_id": contract["contract_id"],
  382. "contract_version": contract["contract_version"],
  383. "schema_digest": contract["schema_digest"],
  384. **contract["runs"][0],
  385. }
  386. tampered = registry_snapshot()
  387. tampered["contracts"][0]["allowed_operations"] = ["catalog"]
  388. monkeypatch.setattr(contract_registry, "CONTRACT_REGISTRY", tampered)
  389. with pytest.raises(DomainReplicationError, match="integrity"):
  390. validate_contract_reference(reference, expected_contract_id=contract["contract_id"])
  391. expired = registry_snapshot()
  392. expired["expires_on"] = "2000-01-01"
  393. monkeypatch.setattr(contract_registry, "CONTRACT_REGISTRY", expired)
  394. monkeypatch.setattr(
  395. contract_registry,
  396. "REGISTRY_SHA256",
  397. hashlib.sha256(contract_registry._canonical(expired)).hexdigest(),
  398. )
  399. with pytest.raises(DomainReplicationError, match="expired"):
  400. validate_contract_reference(reference, expected_contract_id=contract["contract_id"])
  401. monkeypatch.setattr(contract_registry, "CONTRACT_REGISTRY", original_registry)
  402. monkeypatch.setattr(contract_registry, "REGISTRY_SHA256", original_digest)
  403. mismatch = deepcopy(reference)
  404. mismatch["contract_version"] = "999"
  405. with pytest.raises(DomainReplicationError, match="version"):
  406. validate_contract_reference(mismatch, expected_contract_id=contract["contract_id"])
  407. def test_v1_p2_golden_package_is_accepted_without_v2_contract_refs(tmp_path: Path):
  408. package = tmp_path / "p2-v1-copy"
  409. shutil.copytree(P2_PACKAGE_DIR, package)
  410. report = validator.validate_package(package)
  411. assert report["schema_version"] == 1
  412. assert all("contract_refs" not in stage for stage in report["stages"].values())
  413. def test_v2_seed_items_reject_unknown_nested_object_field():
  414. template = _package_json("domain-template.json")
  415. template["seed_data"][0]["items"][0]["unexpected"] = True
  416. with pytest.raises(DomainReplicationError, match="unsupported fields"):
  417. _accept_third_domain(evidence=_package_json("evidence.json"), template=template)
  418. def _accept_third_domain(*, evidence: dict, template: dict | None = None):
  419. return __import__(
  420. "app.core.governance.domain_replication", fromlist=["evaluate_replication_package"]
  421. ).evaluate_replication_package(
  422. _package_json("manifest.json"),
  423. evidence,
  424. template=template or _package_json("domain-template.json"),
  425. snapshot_rows=_package_rows("governance_object_snapshot.csv"),
  426. delta_rows=_package_rows("governance_object_delta.csv"),
  427. execution_evidence=_package_json("execution-evidence.json"),
  428. )