test_phase3_wp01_enterprise_acceptance_contract.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. from __future__ import annotations
  2. import copy
  3. import hashlib
  4. import importlib.util
  5. import json
  6. from pathlib import Path
  7. import pytest
  8. ROOT = Path(__file__).resolve().parents[1]
  9. TEMPLATE = ROOT / "docs/acceptance/P3_WP01_ENTERPRISE_ACCEPTANCE.json"
  10. VALIDATOR_PATH = ROOT / "scripts/validate_phase3_wp01_acceptance.py"
  11. SPEC = importlib.util.spec_from_file_location("phase3_wp01_validator", VALIDATOR_PATH)
  12. assert SPEC and SPEC.loader
  13. validator = importlib.util.module_from_spec(SPEC)
  14. SPEC.loader.exec_module(validator)
  15. def _load_template() -> dict:
  16. return json.loads(TEMPLATE.read_text(encoding="utf-8"))
  17. def _bind_evidence(value: object, root: Path, counter: list[int]) -> None:
  18. if isinstance(value, dict):
  19. for key, child in value.items():
  20. if key == "evidence_refs" and isinstance(child, list):
  21. for reference in child:
  22. counter[0] += 1
  23. name = f"evidence-{counter[0]:03d}.json"
  24. content = json.dumps(
  25. {"evidence_id": counter[0], "enterprise_formal": True},
  26. sort_keys=True,
  27. ).encode()
  28. (root / name).write_bytes(content)
  29. reference.update(
  30. {
  31. "path": name,
  32. "sha256": hashlib.sha256(content).hexdigest(),
  33. "evidence_level": "ENTERPRISE_FORMAL",
  34. }
  35. )
  36. else:
  37. _bind_evidence(child, root, counter)
  38. elif isinstance(value, list):
  39. for child in value:
  40. _bind_evidence(child, root, counter)
  41. def _accepted_bundle(tmp_path: Path) -> Path:
  42. payload = copy.deepcopy(_load_template())
  43. payload["declared_status"] = "ENTERPRISE_ACCEPTANCE_COMPLETE"
  44. payload["execution_context"].update(
  45. {
  46. "enterprise_name": "Synthetic Enterprise",
  47. "environment_id": "preprod-synthetic-01",
  48. "environment_type": "PREPRODUCTION",
  49. "executed_commit": "95023e59bfb9f00be4ed61f7a8307fac56fbcee9",
  50. "started_at": "2026-08-03T09:00:00+08:00",
  51. "completed_at": "2026-08-05T18:00:00+08:00",
  52. }
  53. )
  54. payload["source"].update(
  55. {
  56. "source_id": "enterprise-source-synthetic-01",
  57. "source_type": "POSTGRESQL",
  58. "readonly": True,
  59. "credentials_in_package": False,
  60. "network_zone": "enterprise-preproduction",
  61. }
  62. )
  63. payload["sample_summary"].update(
  64. {
  65. "classification": "DESENSITIZED_ENTERPRISE_SAMPLE",
  66. "object_count": 100,
  67. "row_count": 1000,
  68. "sha256": hashlib.sha256(b"synthetic-enterprise-sample-summary").hexdigest(),
  69. "raw_data_in_evidence": False,
  70. }
  71. )
  72. payload["release_artifact"].update(
  73. {
  74. "version": "v0.3.0-phase2",
  75. "artifact_name": "dataops-phase2-synthetic.tar.gz",
  76. "sha256": hashlib.sha256(b"synthetic-release-artifact").hexdigest(),
  77. }
  78. )
  79. for index, case in enumerate(payload["uat_cases"], start=1):
  80. case.update(
  81. {
  82. "result": "ENTERPRISE_PASS",
  83. "environment_id": "preprod-synthetic-01",
  84. "executed_at": f"2026-08-03T1{index}:00:00+08:00",
  85. "executed_by_person_id": f"executor-{index}",
  86. "approved_by_person_id": f"approver-{index}",
  87. }
  88. )
  89. metric_values = {
  90. "core_object_onboarding_rate": (95, 100),
  91. "incremental_change_accuracy": (99, 100),
  92. "responsibility_coverage": (95, 100),
  93. "quality_issue_closure_rate": (85, 100),
  94. "incident_evidence_coverage": (10, 10),
  95. "data_product_evidence_coverage": (10, 10),
  96. }
  97. for metric in payload["metrics"]:
  98. numerator, denominator = metric_values[metric["id"]]
  99. metric.update(
  100. {
  101. "status": "ENTERPRISE_PASS",
  102. "numerator": numerator,
  103. "denominator": denominator,
  104. "value": numerator / denominator * 100,
  105. "window_start": "2026-08-03T09:00:00+08:00",
  106. "window_end": "2026-08-03T18:00:00+08:00",
  107. "approved_by_person_id": f"metric-approver-{metric['id']}",
  108. }
  109. )
  110. rehearsal = payload["preproduction_rehearsal"]
  111. rehearsal.update(
  112. {
  113. "status": "ENTERPRISE_PASS",
  114. "environment_id": "preprod-synthetic-01",
  115. "executed_at": "2026-08-03T14:00:00+08:00",
  116. "executed_by_person_id": "preprod-operator-1",
  117. "approved_by_person_id": "preprod-approver-1",
  118. "rto_minutes": 25,
  119. "rpo_minutes": 10,
  120. }
  121. )
  122. for step in rehearsal["steps"]:
  123. step["status"] = "ENTERPRISE_PASS"
  124. for index, record in enumerate(payload["training_records"], start=1):
  125. record.update(
  126. {
  127. "status": "ENTERPRISE_PASS",
  128. "participants": [f"trainee-{index}-1", f"trainee-{index}-2"],
  129. "trainer_person_id": f"trainer-{index}",
  130. "completed_at": f"2026-08-04T1{index}:00:00+08:00",
  131. "exercise_passed": True,
  132. "retraining_required": False,
  133. "retraining_completed": False,
  134. }
  135. )
  136. payload["defect_gate"].update(
  137. {
  138. "status": "ENTERPRISE_PASS",
  139. "p0_open": 0,
  140. "p1_open": 0,
  141. "reviewed_by_business_person_id": "business-defect-reviewer",
  142. "reviewed_by_technical_person_id": "technical-defect-reviewer",
  143. "reviewed_at": "2026-08-04T15:00:00+08:00",
  144. }
  145. )
  146. payload["defect_gate"]["p1_disposition"].update(
  147. {"status": "NOT_REQUIRED", "covered_p1_count": 0}
  148. )
  149. for index, signoff in enumerate(payload["signoffs"], start=1):
  150. signoff.update(
  151. {
  152. "status": "SIGNED",
  153. "person_id": f"signer-{index}",
  154. "person_name": f"Synthetic Signer {index}",
  155. "signed_at": f"2026-08-05T1{index}:00:00+08:00",
  156. }
  157. )
  158. _bind_evidence(payload, tmp_path, [0])
  159. package = tmp_path / "enterprise-acceptance.json"
  160. package.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  161. return package
  162. def _replace_first_case_evidence(package: Path, content: bytes) -> None:
  163. payload = json.loads(package.read_text())
  164. reference = payload["uat_cases"][0]["evidence_refs"][0]
  165. (package.parent / reference["path"]).write_bytes(content)
  166. reference["sha256"] = hashlib.sha256(content).hexdigest()
  167. package.write_text(json.dumps(payload), encoding="utf-8")
  168. def _set_nested(payload: object, path: tuple[object, ...], value: object) -> None:
  169. current = payload
  170. for part in path[:-1]:
  171. current = current[part] # type: ignore[index]
  172. current[path[-1]] = value # type: ignore[index]
  173. def test_checked_in_template_is_engineering_ready_and_blocked_external() -> None:
  174. result = validator.validate_acceptance_bundle(TEMPLATE)
  175. assert result["status"] == "BLOCKED"
  176. assert result["declared_status"] == "ENGINEERING_READY_BLOCKED_EXTERNAL"
  177. assert result["blockers"]
  178. assert not result["failures"]
  179. def test_complete_synthetic_enterprise_bundle_is_accepted(tmp_path: Path) -> None:
  180. package = _accepted_bundle(tmp_path)
  181. result = validator.validate_acceptance_bundle(package)
  182. assert result == {
  183. "status": "ACCEPTED",
  184. "declared_status": "ENTERPRISE_ACCEPTANCE_COMPLETE",
  185. "blockers": [],
  186. "failures": [],
  187. }
  188. def test_low_metric_and_local_pass_cannot_be_accepted(tmp_path: Path) -> None:
  189. package = _accepted_bundle(tmp_path)
  190. payload = json.loads(package.read_text())
  191. payload["metrics"][0]["numerator"] = 94
  192. payload["metrics"][0]["value"] = 94
  193. payload["uat_cases"][0]["result"] = "PASS_LOCAL"
  194. package.write_text(json.dumps(payload), encoding="utf-8")
  195. result = validator.validate_acceptance_bundle(package)
  196. assert result["status"] == "FAILED"
  197. assert any("threshold" in failure for failure in result["failures"])
  198. assert any("PASS_LOCAL" in failure for failure in result["failures"])
  199. @pytest.mark.parametrize("invalid_path", ["../escape.json", "/tmp/absolute.json"])
  200. def test_evidence_path_escape_is_rejected(tmp_path: Path, invalid_path: str) -> None:
  201. package = _accepted_bundle(tmp_path)
  202. payload = json.loads(package.read_text())
  203. payload["uat_cases"][0]["evidence_refs"][0]["path"] = invalid_path
  204. package.write_text(json.dumps(payload), encoding="utf-8")
  205. result = validator.validate_acceptance_bundle(package)
  206. assert result["status"] == "FAILED"
  207. assert any("evidence path" in failure for failure in result["failures"])
  208. def test_missing_or_digest_mismatched_evidence_is_rejected(tmp_path: Path) -> None:
  209. package = _accepted_bundle(tmp_path)
  210. payload = json.loads(package.read_text())
  211. first = payload["uat_cases"][0]["evidence_refs"][0]
  212. (tmp_path / first["path"]).unlink()
  213. payload["uat_cases"][1]["evidence_refs"][0]["sha256"] = "0" * 64
  214. package.write_text(json.dumps(payload), encoding="utf-8")
  215. result = validator.validate_acceptance_bundle(package)
  216. assert result["status"] == "FAILED"
  217. assert any("missing evidence" in failure for failure in result["failures"])
  218. assert any("digest mismatch" in failure for failure in result["failures"])
  219. def test_placeholder_file_cannot_pretend_to_be_enterprise_evidence(tmp_path: Path) -> None:
  220. package = _accepted_bundle(tmp_path)
  221. payload = json.loads(package.read_text())
  222. reference = payload["uat_cases"][0]["evidence_refs"][0]
  223. content = b"TBD_EXTERNAL"
  224. (tmp_path / reference["path"]).write_bytes(content)
  225. reference["sha256"] = hashlib.sha256(content).hexdigest()
  226. package.write_text(json.dumps(payload), encoding="utf-8")
  227. result = validator.validate_acceptance_bundle(package)
  228. assert result["status"] == "FAILED"
  229. assert any("placeholder evidence" in failure for failure in result["failures"])
  230. @pytest.mark.parametrize(
  231. "content",
  232. [
  233. b"status: TBD_EXTERNAL\nThe execution steps are documented below.",
  234. json.dumps({"status": "TBD_EXTERNAL", "enterprise_formal": True}).encode(),
  235. b"| field | value |\n|---|---|\n| result | TBD_EXTERNAL |",
  236. b"field,value\nresult,BLOCKED_EXTERNAL",
  237. ],
  238. )
  239. def test_structured_unfinished_state_cannot_be_formal_evidence(
  240. tmp_path: Path, content: bytes
  241. ) -> None:
  242. package = _accepted_bundle(tmp_path)
  243. payload = json.loads(package.read_text())
  244. reference = payload["uat_cases"][0]["evidence_refs"][0]
  245. (tmp_path / reference["path"]).write_bytes(content)
  246. reference["sha256"] = hashlib.sha256(content).hexdigest()
  247. package.write_text(json.dumps(payload), encoding="utf-8")
  248. result = validator.validate_acceptance_bundle(package)
  249. assert result["status"] == "FAILED"
  250. assert any("placeholder evidence" in failure for failure in result["failures"])
  251. def test_checked_in_execution_guide_cannot_be_disguised_as_formal_evidence(
  252. tmp_path: Path,
  253. ) -> None:
  254. package = _accepted_bundle(tmp_path)
  255. payload = json.loads(package.read_text())
  256. reference = payload["uat_cases"][0]["evidence_refs"][0]
  257. content = (ROOT / "docs/acceptance/P3_WP01_EXECUTION_GUIDE.md").read_bytes()
  258. (tmp_path / reference["path"]).write_bytes(content)
  259. reference["sha256"] = hashlib.sha256(content).hexdigest()
  260. package.write_text(json.dumps(payload), encoding="utf-8")
  261. result = validator.validate_acceptance_bundle(package)
  262. assert result["status"] == "FAILED"
  263. assert any("placeholder evidence" in failure for failure in result["failures"])
  264. def test_formal_evidence_may_describe_historical_local_or_blocked_results(tmp_path: Path) -> None:
  265. package = _accepted_bundle(tmp_path)
  266. payload = json.loads(package.read_text())
  267. reference = payload["uat_cases"][0]["evidence_refs"][0]
  268. content = b"Historical PASS_LOCAL and BLOCKED_EXTERNAL results were superseded by this formal run."
  269. (tmp_path / reference["path"]).write_bytes(content)
  270. reference["sha256"] = hashlib.sha256(content).hexdigest()
  271. package.write_text(json.dumps(payload), encoding="utf-8")
  272. assert validator.validate_acceptance_bundle(package)["status"] == "ACCEPTED"
  273. @pytest.mark.parametrize(
  274. "content",
  275. [
  276. json.dumps(
  277. {"previous_status": "BLOCKED_EXTERNAL", "status": "ENTERPRISE_PASS"}
  278. ).encode(),
  279. json.dumps(
  280. {"historical_status": "PASS_LOCAL", "status": "ENTERPRISE_PASS"}
  281. ).encode(),
  282. json.dumps(
  283. {"prior_status": "BLOCKED_EXTERNAL", "status": "ENTERPRISE_PASS"}
  284. ).encode(),
  285. json.dumps(
  286. {"superseded_status": "PASS_LOCAL", "status": "ENTERPRISE_PASS"}
  287. ).encode(),
  288. json.dumps(
  289. {"history": {"status": "BLOCKED_EXTERNAL"}, "status": "ENTERPRISE_PASS"}
  290. ).encode(),
  291. b"| previous_status | notes |\n|---|---|\n| BLOCKED_EXTERNAL | superseded |",
  292. b"previous_status,notes\nPASS_LOCAL,superseded",
  293. b"\xff\xfe\x00\x89binary-evidence",
  294. ],
  295. )
  296. def test_structured_historical_or_binary_evidence_is_allowed(
  297. tmp_path: Path, content: bytes
  298. ) -> None:
  299. package = _accepted_bundle(tmp_path)
  300. _replace_first_case_evidence(package, content)
  301. assert validator.validate_acceptance_bundle(package)["status"] == "ACCEPTED"
  302. @pytest.mark.parametrize(
  303. "content",
  304. [
  305. json.dumps({"current_status": "BLOCKED_EXTERNAL"}).encode(),
  306. json.dumps(
  307. {"status": "ENTERPRISE_PASS", "measurement": "TBD_EXTERNAL"}
  308. ).encode(),
  309. json.dumps({"priority": "TBD_EXTERNAL"}).encode(),
  310. json.dumps({"prioritization": "TBD_EXTERNAL"}).encode(),
  311. json.dumps({"原因": "TBD_EXTERNAL"}, ensure_ascii=False).encode(),
  312. json.dumps({"原值": "TBD_EXTERNAL"}, ensure_ascii=False).encode(),
  313. b"| status | notes |\n|---|---|\n| BLOCKED_EXTERNAL | still open |",
  314. b"| metric | measured_value |\n|---|---|\n| onboarding | TBD_EXTERNAL |",
  315. b"| priority | TBD_EXTERNAL |",
  316. b"state,notes\nPASS_LOCAL,still local",
  317. b"metric,measured_value\nonboarding,TBD_EXTERNAL",
  318. b"priority,TBD_EXTERNAL",
  319. ],
  320. )
  321. def test_structured_current_state_evidence_is_rejected(
  322. tmp_path: Path, content: bytes
  323. ) -> None:
  324. package = _accepted_bundle(tmp_path)
  325. _replace_first_case_evidence(package, content)
  326. result = validator.validate_acceptance_bundle(package)
  327. assert result["status"] == "FAILED"
  328. assert any("placeholder evidence" in failure for failure in result["failures"])
  329. @pytest.mark.parametrize(
  330. ("path", "invalid_value"),
  331. [
  332. (("source_cases",), {"case": "P2-WP13-UAT-021"}),
  333. (("signoffs",), {"role": "product_owner"}),
  334. (("preproduction_rehearsal", "rto_target_minutes"), "30"),
  335. (("training_records", 0, "participants"), [None]),
  336. (("signoffs", 0, "person_name"), 123),
  337. (("metrics", 0, "numerator"), float("nan")),
  338. (("metrics", 0, "value"), float("inf")),
  339. (("preproduction_rehearsal", "rto_minutes"), float("nan")),
  340. (("preproduction_rehearsal", "rpo_minutes"), float("inf")),
  341. ],
  342. )
  343. def test_malformed_types_and_non_finite_numbers_fail_without_raising(
  344. tmp_path: Path, path: tuple[object, ...], invalid_value: object
  345. ) -> None:
  346. package = _accepted_bundle(tmp_path)
  347. payload = json.loads(package.read_text())
  348. _set_nested(payload, path, invalid_value)
  349. package.write_text(json.dumps(payload), encoding="utf-8")
  350. result = validator.validate_acceptance_bundle(package)
  351. assert result["status"] == "FAILED"
  352. assert result["failures"]
  353. def test_self_approval_and_five_party_proxy_signing_are_rejected(tmp_path: Path) -> None:
  354. package = _accepted_bundle(tmp_path)
  355. payload = json.loads(package.read_text())
  356. payload["uat_cases"][0]["approved_by_person_id"] = payload["uat_cases"][0][
  357. "executed_by_person_id"
  358. ]
  359. payload["signoffs"][1]["person_id"] = payload["signoffs"][0]["person_id"]
  360. package.write_text(json.dumps(payload), encoding="utf-8")
  361. result = validator.validate_acceptance_bundle(package)
  362. assert result["status"] == "FAILED"
  363. assert any("self-approval" in failure for failure in result["failures"])
  364. assert any("proxy signing" in failure for failure in result["failures"])
  365. def test_open_p0_defect_blocks_acceptance(tmp_path: Path) -> None:
  366. package = _accepted_bundle(tmp_path)
  367. payload = json.loads(package.read_text())
  368. payload["defect_gate"]["p0_open"] = 1
  369. package.write_text(json.dumps(payload), encoding="utf-8")
  370. result = validator.validate_acceptance_bundle(package)
  371. assert result["status"] == "FAILED"
  372. assert any("p0_open" in failure for failure in result["failures"])
  373. def test_open_p1_with_dual_written_decision_is_accepted(tmp_path: Path) -> None:
  374. package = _accepted_bundle(tmp_path)
  375. payload = json.loads(package.read_text())
  376. payload["defect_gate"]["p1_open"] = 2
  377. payload["defect_gate"]["open_p1_ids"] = ["P1-UAT-021-001", "P1-UAT-023-002"]
  378. payload["defect_gate"]["p1_disposition"].update(
  379. {
  380. "status": "WRITTEN_DECISION_APPROVED",
  381. "covered_p1_count": 2,
  382. "covered_p1_ids": ["P1-UAT-023-002", "P1-UAT-021-001"],
  383. "business_person_id": "p1-business-approver",
  384. "technical_person_id": "p1-technical-approver",
  385. "decided_at": "2026-08-04T14:30:00+08:00",
  386. }
  387. )
  388. package.write_text(json.dumps(payload), encoding="utf-8")
  389. assert validator.validate_acceptance_bundle(package)["status"] == "ACCEPTED"
  390. def test_open_p1_without_written_decision_is_rejected(tmp_path: Path) -> None:
  391. package = _accepted_bundle(tmp_path)
  392. payload = json.loads(package.read_text())
  393. payload["defect_gate"]["p1_open"] = 1
  394. package.write_text(json.dumps(payload), encoding="utf-8")
  395. result = validator.validate_acceptance_bundle(package)
  396. assert result["status"] == "FAILED"
  397. assert any("WRITTEN_DECISION_APPROVED" in failure for failure in result["failures"])
  398. def test_open_p1_ids_must_be_unique_and_exactly_covered(tmp_path: Path) -> None:
  399. package = _accepted_bundle(tmp_path)
  400. payload = json.loads(package.read_text())
  401. payload["defect_gate"]["p1_open"] = 2
  402. payload["defect_gate"]["open_p1_ids"] = ["P1-UAT-021-001", "P1-UAT-021-001"]
  403. payload["defect_gate"]["p1_disposition"].update(
  404. {
  405. "status": "WRITTEN_DECISION_APPROVED",
  406. "covered_p1_count": 2,
  407. "covered_p1_ids": ["P1-UAT-021-001", "TBD_EXTERNAL"],
  408. "business_person_id": "p1-business-approver",
  409. "technical_person_id": "p1-technical-approver",
  410. "decided_at": "2026-08-04T14:30:00+08:00",
  411. }
  412. )
  413. package.write_text(json.dumps(payload), encoding="utf-8")
  414. result = validator.validate_acceptance_bundle(package)
  415. assert result["status"] == "FAILED"
  416. assert any("unique P1 ids" in failure for failure in result["failures"])
  417. assert any("invalid or placeholder P1 id" in failure for failure in result["failures"])
  418. @pytest.mark.parametrize(
  419. ("field_path", "wrong_value", "message"),
  420. [
  421. (("release_artifact", "version"), "v0.3.0-phase2-hotfix", "release_artifact.version"),
  422. (("execution_context", "executed_commit"), "5267d8bd8b30a58ed5fb6ec855ea579e7ce34d69", "executed_commit"),
  423. ],
  424. )
  425. def test_release_version_and_tag_commit_are_exact(
  426. tmp_path: Path, field_path: tuple[str, str], wrong_value: str, message: str
  427. ) -> None:
  428. package = _accepted_bundle(tmp_path)
  429. payload = json.loads(package.read_text())
  430. payload[field_path[0]][field_path[1]] = wrong_value
  431. package.write_text(json.dumps(payload), encoding="utf-8")
  432. result = validator.validate_acceptance_bundle(package)
  433. assert result["status"] == "FAILED"
  434. assert any(message in failure for failure in result["failures"])
  435. def test_reversed_context_or_metric_window_is_rejected(tmp_path: Path) -> None:
  436. package = _accepted_bundle(tmp_path)
  437. payload = json.loads(package.read_text())
  438. payload["execution_context"]["started_at"] = "2026-08-05T19:00:00+08:00"
  439. payload["metrics"][0]["window_start"] = "2026-08-03T19:00:00+08:00"
  440. package.write_text(json.dumps(payload), encoding="utf-8")
  441. result = validator.validate_acceptance_bundle(package)
  442. assert result["status"] == "FAILED"
  443. assert any("started_at must not be after completed_at" in failure for failure in result["failures"])
  444. assert any("window_start must not be after window_end" in failure for failure in result["failures"])
  445. def test_case_and_rehearsal_environment_must_match_context(tmp_path: Path) -> None:
  446. package = _accepted_bundle(tmp_path)
  447. payload = json.loads(package.read_text())
  448. payload["uat_cases"][0]["environment_id"] = "preprod-other"
  449. payload["preproduction_rehearsal"]["environment_id"] = "preprod-other"
  450. package.write_text(json.dumps(payload), encoding="utf-8")
  451. result = validator.validate_acceptance_bundle(package)
  452. assert result["status"] == "FAILED"
  453. assert sum("must match execution_context.environment_id" in failure for failure in result["failures"]) == 2
  454. def test_signoff_cannot_precede_acceptance_completion_events(tmp_path: Path) -> None:
  455. package = _accepted_bundle(tmp_path)
  456. payload = json.loads(package.read_text())
  457. payload["signoffs"][0]["signed_at"] = "2026-08-03T10:00:00+08:00"
  458. package.write_text(json.dumps(payload), encoding="utf-8")
  459. result = validator.validate_acceptance_bundle(package)
  460. assert result["status"] == "FAILED"
  461. assert any("must not precede completion event" in failure for failure in result["failures"])
  462. def test_incomplete_rehearsal_or_training_is_rejected(tmp_path: Path) -> None:
  463. package = _accepted_bundle(tmp_path)
  464. payload = json.loads(package.read_text())
  465. payload["preproduction_rehearsal"]["steps"][2]["status"] = "BLOCKED_EXTERNAL"
  466. payload["training_records"][0]["exercise_passed"] = False
  467. package.write_text(json.dumps(payload), encoding="utf-8")
  468. result = validator.validate_acceptance_bundle(package)
  469. assert result["status"] == "FAILED"
  470. assert any("rehearsal" in failure for failure in result["failures"])
  471. assert any("training" in failure for failure in result["failures"])