| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- import hashlib
- from pathlib import Path
- import pytest
- from fastapi.testclient import TestClient
- from ontorefactor_governance import api, builds
- from ontorefactor_governance.builds import publish_ontology_build, run_ontology_build
- from ontorefactor_governance.db import Database
- from ontorefactor_governance.report import get_report, render_markdown
- from ontorefactor_governance.service import (
- GovernanceError, seed_challenge_cup_demo, validate_project,
- )
- SAMPLES = Path(__file__).parents[1] / "src" / "ontorefactor_governance" / "static" / "samples"
- def demo_sources():
- definitions = (
- ("01-dcp-user.sql", "ddl"),
- ("02-user-profile-openapi.yaml", "openapi"),
- ("03-dcp-asset-inventory.json", "inventory"),
- ("04-governance-evidence.json", "inventory"),
- )
- return [
- {"source_name": name, "source_type": source_type, "content": (SAMPLES / name).read_text(encoding="utf-8")}
- for name, source_type in definitions
- ]
- def publish_demo(db, tenant, project_id, target="dcp_user.mobile"):
- build = run_ontology_build(
- db, tenant, project_id, demo_sources(),
- semantic_mode="deterministic", instruction="从上传证据构建候选本体",
- environment="production", decision_target=target,
- )
- return publish_ontology_build(
- db, tenant, project_id, build["build_id"],
- reviewer="test-reviewer", accept_semantic_assertions=False, decision_target=target,
- )
- def mobile_dossier(report):
- return next(
- item for item in report["refactor_dossiers"]["full"]
- if item["asset"]["uri"].endswith(":dcp_user:mobile")
- )
- def test_publish_generates_point_in_time_report():
- db = Database("sqlite:///:memory:")
- tenant = "report-test"
- project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
- published = publish_demo(db, tenant, project_id)
- assert published["report"]["error"] == ""
- assert published["report"]["id"]
- stored = get_report(db, tenant, project_id, "latest")
- assert stored["version"]["label"] == "v1"
- report = stored["report_json"]
- for section in (
- "format_version", "generator", "header", "assets", "governance_coverage",
- "lineage", "refactor_dossiers", "issues", "review_queue", "diff", "scope",
- ):
- assert section in report, section
- header = report["header"]
- assert header["point_in_time"] is True
- assert header["published_by"] == "test-reviewer"
- assert header["semantic_mode"] == "deterministic"
- expected = {
- name: hashlib.sha256((SAMPLES / name).read_text(encoding="utf-8").encode("utf-8")).hexdigest()
- for name in ("01-dcp-user.sql", "02-user-profile-openapi.yaml",
- "03-dcp-asset-inventory.json", "04-governance-evidence.json")
- }
- assert {item["name"]: item["sha256"] for item in header["sources"]} == expected
- dossier = mobile_dossier(report)
- assert dossier["gate_status"]["status"] == "BLOCKED"
- assert dossier["gate_status"]["basis"] == "accepted+pending"
- assert published["decision"]["status"] == "BLOCKED"
- db.close()
- def test_coverage_gap_matches_validation_findings():
- db = Database("sqlite:///:memory:")
- tenant = "report-coverage"
- project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
- publish_demo(db, tenant, project_id)
- report = get_report(db, tenant, project_id, "latest")["report_json"]
- findings = validate_project(db, tenant, project_id, persist=False)["findings"]
- owner_gap = {f["target_id"] for f in findings if f["code"] == "PRODUCTION_ASSET_WITHOUT_OWNER"}
- policy_gap = {f["target_id"] for f in findings if f["code"] == "SENSITIVE_FIELD_WITHOUT_POLICY"}
- assert {i["id"] for i in report["governance_coverage"]["OWNED_BY"]["uncovered"]} == owner_gap
- assert {i["id"] for i in report["governance_coverage"]["GOVERNED_BY"]["uncovered"]} == policy_gap
- for predicate in ("OWNED_BY", "GOVERNED_BY", "CLASSIFIED_AS", "VALIDATES"):
- section = report["governance_coverage"][predicate]
- assert section["definition"]
- assert section["eligible"] == section["covered"] + len(section["uncovered"])
- db.close()
- def test_dossier_ranking_and_contents():
- db = Database("sqlite:///:memory:")
- tenant = "report-dossier"
- project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
- publish_demo(db, tenant, project_id)
- report = get_report(db, tenant, project_id, "latest")["report_json"]
- dossiers = report["refactor_dossiers"]
- assert dossiers["high_risk_total"] >= 1
- first = dossiers["full"][0]
- assert first["asset"]["uri"].endswith(":dcp_user:mobile")
- assert set(first["risk_flags"]) == {"sensitive", "governed", "validated", "has-consumers"}
- dossier = mobile_dossier(report)
- assert len(dossier["consumers"]["reads"]) >= 3 # API + 两个任务仍读取 mobile
- assert dossier["owner"] and dossier["policies"]
- assert any(gate.get("dimension") == "completeness" for gate in dossier["quality_gates"])
- assert dossier["evidence_ids"]
- consumers = report["lineage"]["consumers"]
- assert consumers[dossier["asset"]["id"]]["reads"] == len(dossier["consumers"]["reads"])
- db.close()
- def test_diff_first_and_second_version():
- db = Database("sqlite:///:memory:")
- tenant = "report-diff"
- project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
- first = publish_demo(db, tenant, project_id)
- report_v1 = get_report(db, tenant, project_id, first["version"]["id"])["report_json"]
- assert report_v1["diff"]["previous_version"] is None
- assert report_v1["diff"]["elements"]["added_count"] == len(first["version"]["element_ids"])
- assert report_v1["diff"]["elements"]["removed_count"] == 0
- second = publish_demo(db, tenant, project_id)
- report_v2 = get_report(db, tenant, project_id, "latest")["report_json"]
- assert report_v2["header"]["label"] == "v2"
- assert report_v2["diff"]["previous_version"]["label"] == "v1"
- assert report_v2["diff"]["elements"]["added_count"] == 0
- assert report_v2["diff"]["elements"]["removed_count"] == 0
- assert report_v2["diff"]["elements"]["unchanged_count"] == len(first["version"]["element_ids"])
- db.close()
- def test_review_queue_lists_pending_inferred_assertions():
- db = Database("sqlite:///:memory:")
- tenant = "report-queue"
- project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
- publish_demo(db, tenant, project_id)
- report = get_report(db, tenant, project_id, "latest")["report_json"]
- queue = report["review_queue"]
- assert queue["total"] > 0
- assert queue["total"] == len(queue["items"])
- inferred = [item for item in queue["items"] if item["assertion_type"] == "inferred"]
- assert inferred and all(item["generated_by"] for item in inferred)
- db.close()
- def test_markdown_rendering_is_deterministic_and_complete():
- db = Database("sqlite:///:memory:")
- tenant = "report-md"
- project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
- publish_demo(db, tenant, project_id)
- report = get_report(db, tenant, project_id, "latest")["report_json"]
- first, second = render_markdown(report), render_markdown(report)
- assert first == second
- for heading in (
- "# 治理报告 v1", "## 1. 来源与版本", "## 2. 资产清单", "## 3. 治理覆盖度",
- "## 4. 血缘摘要", "## 5. 重构档案", "## 6. 风险与问题", "## 7. 待裁决与版本变更",
- "## 附录:口径与限制",
- ):
- assert heading in first, heading
- assert "BLOCKED" in first
- assert "发布时点" in first
- hostile = {**report, "assets": {**report["assets"], "items": [
- {"id": "x", "kind": "Table", "name": "a|b\nc", "uri": "u", "status": "published",
- "environment": "production", "sensitive": False},
- ], "total": 1, "by_kind": {"Table": 1}, "truncated": False}}
- rendered = render_markdown(hostile)
- assert "a\\|b c" in rendered
- db.close()
- def test_report_failure_does_not_block_publish(monkeypatch):
- def boom(*args, **kwargs):
- raise RuntimeError("synthetic report failure")
- db = Database("sqlite:///:memory:")
- tenant = "report-fail"
- project_id = seed_challenge_cup_demo(db, tenant)["project"]["id"]
- build = run_ontology_build(
- db, tenant, project_id, demo_sources(), semantic_mode="deterministic",
- )
- monkeypatch.setattr(builds, "build_governance_report", boom)
- published = publish_ontology_build(db, tenant, project_id, build["build_id"], reviewer="test-reviewer")
- assert published["version"]["label"] == "v1"
- assert published["report"]["id"] is None
- assert "synthetic report failure" in published["report"]["error"]
- issues = db.fetchall(
- "SELECT rule_id FROM governance_issues WHERE tenant_id=? AND project_id=? AND rule_id='report:generation-failed'",
- (tenant, project_id),
- )
- assert len(issues) == 1
- with pytest.raises(GovernanceError) as caught:
- get_report(db, tenant, project_id, "latest")
- assert caught.value.code == "REPORT_NOT_FOUND"
- assert caught.value.status == 404
- # 自愈:报告生成器恢复后,重复发布走幂等路径不补报告,保持只读
- monkeypatch.undo()
- republished = publish_ontology_build(db, tenant, project_id, build["build_id"], reviewer="test-reviewer")
- assert republished["idempotent"] is True
- assert republished["report"] is None
- db.close()
- def test_report_api_endpoint(monkeypatch):
- db = Database("sqlite:///:memory:")
- monkeypatch.setattr(api, "get_database", lambda: db)
- client = TestClient(api.app)
- headers = {"X-Tenant-ID": "report-api"}
- project_id = client.post("/api/v1/governance/projects", headers=headers, json={
- "name": "Report API", "namespace": "urn:report:api",
- }).json()["id"]
- build = client.post(
- f"/api/v1/governance/projects/{project_id}/ontology-builds", headers=headers,
- json={"sources": [
- {"source_type": item["source_type"], "source_name": item["source_name"], "content": item["content"]}
- for item in demo_sources()
- ], "environment": "production", "decision_target": "dcp_user.mobile",
- "semantic_mode": "deterministic"},
- )
- assert build.status_code == 200, build.text
- build_id = build.json()["build_id"]
- published = client.post(
- f"/api/v1/governance/projects/{project_id}/ontology-builds/{build_id}/publish",
- headers=headers, json={"reviewer": "api-reviewer", "decision_target": "dcp_user.mobile"},
- )
- assert published.status_code == 200, published.text
- report_id = published.json()["report"]["id"]
- assert report_id
- versions = client.get(f"/api/v1/governance/projects/{project_id}/ontology-versions", headers=headers).json()
- version_id = versions["versions"][0]["id"]
- assert versions["versions"][0]["report_id"] == report_id
- as_json = client.get(
- f"/api/v1/governance/projects/{project_id}/ontology-versions/latest/report",
- headers=headers, params={"format": "json"},
- )
- assert as_json.status_code == 200
- for section in ("header", "assets", "governance_coverage", "refactor_dossiers", "review_queue"):
- assert section in as_json.json(), section
- by_id = client.get(
- f"/api/v1/governance/projects/{project_id}/ontology-versions/{version_id}/report", headers=headers,
- )
- assert by_id.status_code == 200
- assert by_id.json() == as_json.json()
- as_md = client.get(
- f"/api/v1/governance/projects/{project_id}/ontology-versions/latest/report",
- headers=headers, params={"format": "md"},
- )
- assert as_md.status_code == 200
- assert as_md.headers["content-type"].startswith("text/markdown")
- assert "# 治理报告 v1" in as_md.text
- assert "governance-report.md" in as_md.headers["content-disposition"]
- assert client.get(
- f"/api/v1/governance/projects/{project_id}/ontology-versions/latest/report",
- headers={"X-Tenant-ID": "other"},
- ).status_code == 404
- assert client.get(
- f"/api/v1/governance/projects/{project_id}/ontology-versions/ov_missing/report", headers=headers,
- ).status_code == 404
- assert client.get(
- f"/api/v1/governance/projects/{project_id}/ontology-versions/latest/report",
- headers=headers, params={"format": "xml"},
- ).status_code == 422
- db.close()
|