test_report.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import hashlib
  2. from pathlib import Path
  3. import pytest
  4. from fastapi.testclient import TestClient
  5. from ontorefactor_governance import api, builds
  6. from ontorefactor_governance.builds import publish_ontology_build, run_ontology_build
  7. from ontorefactor_governance.db import Database
  8. from ontorefactor_governance.report import get_report, render_markdown
  9. from ontorefactor_governance.service import (
  10. GovernanceError, seed_challenge_cup_demo, validate_project,
  11. )
  12. SAMPLES = Path(__file__).parents[1] / "src" / "ontorefactor_governance" / "static" / "samples"
  13. def demo_sources():
  14. definitions = (
  15. ("01-dcp-user.sql", "ddl"),
  16. ("02-user-profile-openapi.yaml", "openapi"),
  17. ("03-dcp-asset-inventory.json", "inventory"),
  18. ("04-governance-evidence.json", "inventory"),
  19. )
  20. return [
  21. {"source_name": name, "source_type": source_type, "content": (SAMPLES / name).read_text(encoding="utf-8")}
  22. for name, source_type in definitions
  23. ]
  24. def publish_demo(db, tenant, project_id, target="dcp_user.mobile"):
  25. build = run_ontology_build(
  26. db, tenant, project_id, demo_sources(),
  27. semantic_mode="deterministic", instruction="从上传证据构建候选本体",
  28. environment="production", decision_target=target,
  29. )
  30. return publish_ontology_build(
  31. db, tenant, project_id, build["build_id"],
  32. reviewer="test-reviewer", accept_semantic_assertions=False, decision_target=target,
  33. )
  34. def mobile_dossier(report):
  35. return next(
  36. item for item in report["refactor_dossiers"]["full"]
  37. if item["asset"]["uri"].endswith(":dcp_user:mobile")
  38. )
  39. def test_publish_generates_point_in_time_report():
  40. db = Database("sqlite:///:memory:")
  41. tenant = "report-test"
  42. project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
  43. published = publish_demo(db, tenant, project_id)
  44. assert published["report"]["error"] == ""
  45. assert published["report"]["id"]
  46. stored = get_report(db, tenant, project_id, "latest")
  47. assert stored["version"]["label"] == "v1"
  48. report = stored["report_json"]
  49. for section in (
  50. "format_version", "generator", "header", "assets", "governance_coverage",
  51. "lineage", "refactor_dossiers", "issues", "review_queue", "diff", "scope",
  52. ):
  53. assert section in report, section
  54. header = report["header"]
  55. assert header["point_in_time"] is True
  56. assert header["published_by"] == "test-reviewer"
  57. assert header["semantic_mode"] == "deterministic"
  58. expected = {
  59. name: hashlib.sha256((SAMPLES / name).read_text(encoding="utf-8").encode("utf-8")).hexdigest()
  60. for name in ("01-dcp-user.sql", "02-user-profile-openapi.yaml",
  61. "03-dcp-asset-inventory.json", "04-governance-evidence.json")
  62. }
  63. assert {item["name"]: item["sha256"] for item in header["sources"]} == expected
  64. dossier = mobile_dossier(report)
  65. assert dossier["gate_status"]["status"] == "BLOCKED"
  66. assert dossier["gate_status"]["basis"] == "accepted+pending"
  67. assert published["decision"]["status"] == "BLOCKED"
  68. db.close()
  69. def test_coverage_gap_matches_validation_findings():
  70. db = Database("sqlite:///:memory:")
  71. tenant = "report-coverage"
  72. project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
  73. publish_demo(db, tenant, project_id)
  74. report = get_report(db, tenant, project_id, "latest")["report_json"]
  75. findings = validate_project(db, tenant, project_id, persist=False)["findings"]
  76. owner_gap = {f["target_id"] for f in findings if f["code"] == "PRODUCTION_ASSET_WITHOUT_OWNER"}
  77. policy_gap = {f["target_id"] for f in findings if f["code"] == "SENSITIVE_FIELD_WITHOUT_POLICY"}
  78. assert {i["id"] for i in report["governance_coverage"]["OWNED_BY"]["uncovered"]} == owner_gap
  79. assert {i["id"] for i in report["governance_coverage"]["GOVERNED_BY"]["uncovered"]} == policy_gap
  80. for predicate in ("OWNED_BY", "GOVERNED_BY", "CLASSIFIED_AS", "VALIDATES"):
  81. section = report["governance_coverage"][predicate]
  82. assert section["definition"]
  83. assert section["eligible"] == section["covered"] + len(section["uncovered"])
  84. db.close()
  85. def test_dossier_ranking_and_contents():
  86. db = Database("sqlite:///:memory:")
  87. tenant = "report-dossier"
  88. project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
  89. publish_demo(db, tenant, project_id)
  90. report = get_report(db, tenant, project_id, "latest")["report_json"]
  91. dossiers = report["refactor_dossiers"]
  92. assert dossiers["high_risk_total"] >= 1
  93. first = dossiers["full"][0]
  94. assert first["asset"]["uri"].endswith(":dcp_user:mobile")
  95. assert set(first["risk_flags"]) == {"sensitive", "governed", "validated", "has-consumers"}
  96. dossier = mobile_dossier(report)
  97. assert len(dossier["consumers"]["reads"]) >= 3 # API + 两个任务仍读取 mobile
  98. assert dossier["owner"] and dossier["policies"]
  99. assert any(gate.get("dimension") == "completeness" for gate in dossier["quality_gates"])
  100. assert dossier["evidence_ids"]
  101. consumers = report["lineage"]["consumers"]
  102. assert consumers[dossier["asset"]["id"]]["reads"] == len(dossier["consumers"]["reads"])
  103. db.close()
  104. def test_diff_first_and_second_version():
  105. db = Database("sqlite:///:memory:")
  106. tenant = "report-diff"
  107. project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
  108. first = publish_demo(db, tenant, project_id)
  109. report_v1 = get_report(db, tenant, project_id, first["version"]["id"])["report_json"]
  110. assert report_v1["diff"]["previous_version"] is None
  111. assert report_v1["diff"]["elements"]["added_count"] == len(first["version"]["element_ids"])
  112. assert report_v1["diff"]["elements"]["removed_count"] == 0
  113. second = publish_demo(db, tenant, project_id)
  114. report_v2 = get_report(db, tenant, project_id, "latest")["report_json"]
  115. assert report_v2["header"]["label"] == "v2"
  116. assert report_v2["diff"]["previous_version"]["label"] == "v1"
  117. assert report_v2["diff"]["elements"]["added_count"] == 0
  118. assert report_v2["diff"]["elements"]["removed_count"] == 0
  119. assert report_v2["diff"]["elements"]["unchanged_count"] == len(first["version"]["element_ids"])
  120. db.close()
  121. def test_review_queue_lists_pending_inferred_assertions():
  122. db = Database("sqlite:///:memory:")
  123. tenant = "report-queue"
  124. project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
  125. publish_demo(db, tenant, project_id)
  126. report = get_report(db, tenant, project_id, "latest")["report_json"]
  127. queue = report["review_queue"]
  128. assert queue["total"] > 0
  129. assert queue["total"] == len(queue["items"])
  130. inferred = [item for item in queue["items"] if item["assertion_type"] == "inferred"]
  131. assert inferred and all(item["generated_by"] for item in inferred)
  132. db.close()
  133. def test_markdown_rendering_is_deterministic_and_complete():
  134. db = Database("sqlite:///:memory:")
  135. tenant = "report-md"
  136. project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
  137. publish_demo(db, tenant, project_id)
  138. report = get_report(db, tenant, project_id, "latest")["report_json"]
  139. first, second = render_markdown(report), render_markdown(report)
  140. assert first == second
  141. for heading in (
  142. "# 治理报告 v1", "## 1. 来源与版本", "## 2. 资产清单", "## 3. 治理覆盖度",
  143. "## 4. 血缘摘要", "## 5. 重构档案", "## 6. 风险与问题", "## 7. 待裁决与版本变更",
  144. "## 附录:口径与限制",
  145. ):
  146. assert heading in first, heading
  147. assert "BLOCKED" in first
  148. assert "发布时点" in first
  149. hostile = {**report, "assets": {**report["assets"], "items": [
  150. {"id": "x", "kind": "Table", "name": "a|b\nc", "uri": "u", "status": "published",
  151. "environment": "production", "sensitive": False},
  152. ], "total": 1, "by_kind": {"Table": 1}, "truncated": False}}
  153. rendered = render_markdown(hostile)
  154. assert "a\\|b c" in rendered
  155. db.close()
  156. def test_report_failure_does_not_block_publish(monkeypatch):
  157. def boom(*args, **kwargs):
  158. raise RuntimeError("synthetic report failure")
  159. db = Database("sqlite:///:memory:")
  160. tenant = "report-fail"
  161. project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
  162. build = run_ontology_build(
  163. db, tenant, project_id, demo_sources(), semantic_mode="deterministic",
  164. )
  165. monkeypatch.setattr(builds, "build_governance_report", boom)
  166. published = publish_ontology_build(db, tenant, project_id, build["build_id"], reviewer="test-reviewer")
  167. assert published["version"]["label"] == "v1"
  168. assert published["report"]["id"] is None
  169. assert "synthetic report failure" in published["report"]["error"]
  170. issues = db.fetchall(
  171. "SELECT rule_id FROM governance_issues WHERE tenant_id=? AND project_id=? AND rule_id='report:generation-failed'",
  172. (tenant, project_id),
  173. )
  174. assert len(issues) == 1
  175. with pytest.raises(GovernanceError) as caught:
  176. get_report(db, tenant, project_id, "latest")
  177. assert caught.value.code == "REPORT_NOT_FOUND"
  178. assert caught.value.status == 404
  179. # 自愈:报告生成器恢复后,重复发布走幂等路径不补报告,保持只读
  180. monkeypatch.undo()
  181. republished = publish_ontology_build(db, tenant, project_id, build["build_id"], reviewer="test-reviewer")
  182. assert republished["idempotent"] is True
  183. assert republished["report"] is None
  184. db.close()
  185. def test_report_api_endpoint(monkeypatch):
  186. db = Database("sqlite:///:memory:")
  187. monkeypatch.setattr(api, "get_database", lambda: db)
  188. client = TestClient(api.app)
  189. headers = {"X-Tenant-ID": "report-api"}
  190. project_id = client.post("/api/v1/governance/projects", headers=headers, json={
  191. "name": "Report API", "namespace": "urn:report:api",
  192. }).json()["id"]
  193. build = client.post(
  194. f"/api/v1/governance/projects/{project_id}/ontology-builds", headers=headers,
  195. json={"sources": [
  196. {"source_type": item["source_type"], "source_name": item["source_name"], "content": item["content"]}
  197. for item in demo_sources()
  198. ], "environment": "production", "decision_target": "dcp_user.mobile",
  199. "semantic_mode": "deterministic"},
  200. )
  201. assert build.status_code == 200, build.text
  202. build_id = build.json()["build_id"]
  203. published = client.post(
  204. f"/api/v1/governance/projects/{project_id}/ontology-builds/{build_id}/publish",
  205. headers=headers, json={"reviewer": "api-reviewer", "decision_target": "dcp_user.mobile"},
  206. )
  207. assert published.status_code == 200, published.text
  208. report_id = published.json()["report"]["id"]
  209. assert report_id
  210. versions = client.get(f"/api/v1/governance/projects/{project_id}/ontology-versions", headers=headers).json()
  211. version_id = versions["versions"][0]["id"]
  212. assert versions["versions"][0]["report_id"] == report_id
  213. as_json = client.get(
  214. f"/api/v1/governance/projects/{project_id}/ontology-versions/latest/report",
  215. headers=headers, params={"format": "json"},
  216. )
  217. assert as_json.status_code == 200
  218. for section in ("header", "assets", "governance_coverage", "refactor_dossiers", "review_queue"):
  219. assert section in as_json.json(), section
  220. by_id = client.get(
  221. f"/api/v1/governance/projects/{project_id}/ontology-versions/{version_id}/report", headers=headers,
  222. )
  223. assert by_id.status_code == 200
  224. assert by_id.json() == as_json.json()
  225. as_md = client.get(
  226. f"/api/v1/governance/projects/{project_id}/ontology-versions/latest/report",
  227. headers=headers, params={"format": "md"},
  228. )
  229. assert as_md.status_code == 200
  230. assert as_md.headers["content-type"].startswith("text/markdown")
  231. assert "# 治理报告 v1" in as_md.text
  232. assert "governance-report.md" in as_md.headers["content-disposition"]
  233. assert client.get(
  234. f"/api/v1/governance/projects/{project_id}/ontology-versions/latest/report",
  235. headers={"X-Tenant-ID": "other"},
  236. ).status_code == 404
  237. assert client.get(
  238. f"/api/v1/governance/projects/{project_id}/ontology-versions/ov_missing/report", headers=headers,
  239. ).status_code == 404
  240. assert client.get(
  241. f"/api/v1/governance/projects/{project_id}/ontology-versions/latest/report",
  242. headers=headers, params={"format": "xml"},
  243. ).status_code == 422
  244. db.close()