"""ReportDocument v1 contract validator tests (Python side). Semantics are shared with contracts/personal-report/report-document.v1.schema.json and frontend/src/lib/personal-report-contract.ts (Zod). Tests here pin the runtime-enforced rules that JSON Schema draft-07 cannot express and prove the validator never raises on arbitrary/malformed input. """ from __future__ import annotations import json from pathlib import Path import pytest from scripts.personal_report_contract import ( FAILURE_CODES, MAX_SERIALIZED_BYTES, compute_evidence_hash, is_valid_report_document, load_report_document, main, parse_report_document_json, serialized_bytes, validate_report_document, ) ROOT = Path(__file__).resolve().parents[1] FIXTURE = ROOT / "tests" / "fixtures" / "personal_report_document.v1.json" SUPABASE_MIGRATION = ROOT / "frontend" / "supabase" / "migrations" / "20260806010000_personal_reports.sql" LOCAL_MIGRATION = ROOT / "frontend" / "db" / "migrations" / "20260806000000_personal_reports.sql" @pytest.fixture(scope="module") def fixture() -> dict: return load_report_document(str(FIXTURE)) def test_fixture_is_valid_and_hash_is_recomputed(fixture: dict) -> None: result = validate_report_document(fixture) assert result.valid, result.errors # evidenceHash is a deterministic recomputation, not a model self-report. assert fixture["provenance"]["evidenceHash"] == compute_evidence_hash(fixture) assert serialized_bytes(fixture) <= MAX_SERIALIZED_BYTES def test_canonical_hash_is_cross_language_stable(fixture: dict) -> None: # The fixture is read byte-for-byte by the TS tests too; both sides must # compute the same sha256 over the canonical evidence appendix. assert fixture["provenance"]["evidenceHash"] == ( "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4" ) def test_report_document_v1_keeps_legacy_skill_identity_compatibility(fixture: dict) -> None: assert "skillName" not in fixture["provenance"] assert "skillVersion" not in fixture["provenance"] assert validate_report_document(fixture).valid def test_optional_skill_identity_accepts_registry_values_and_rejects_malformed_values(fixture: dict) -> None: current = json.loads(json.dumps(fixture)) current["provenance"]["skillName"] = "jyotish-vedic-astrology" current["provenance"]["skillVersion"] = "6.9.14" assert validate_report_document(current).valid bad_name = json.loads(json.dumps(fixture)) bad_name["provenance"]["skillName"] = "Bad Package" assert validate_report_document(bad_name).valid is False bad_version = json.loads(json.dumps(fixture)) bad_version["provenance"]["skillVersion"] = "v6" assert validate_report_document(bad_version).valid is False @pytest.mark.parametrize( "malformed", [ None, 42, "text", [], {}, {"schemaVersion": "report_document.v1"}, {"evidenceAppendix": {"techniqueAudit": ["not-a-row"], "conflicts": None, "calculationEvidence": [{"id": 5}]}}, {"evidenceAppendix": {"techniqueAudit": [{"id": "ev-a", "notes": {}}]}, "thematicNarrative": [{"id": 1}]}, {"charts": [{"id": "D1", "houses": "broken"}], "evidenceAppendix": {}, "thematicNarrative": "broken"}, ], ) def test_malformed_documents_return_invalid_never_raise(malformed: object) -> None: result = validate_report_document(malformed) assert result.valid is False assert isinstance(result.errors, list) def test_arbitrary_json_text_never_raises() -> None: for text in ["", "not json", '{"a":', "[1,2,3]", '{"schemaVersion": 5}', "null", "42"]: result = parse_report_document_json(text) assert result.valid is False def test_charts_require_exactly_one_d1(fixture: dict) -> None: without_d1 = json.loads(json.dumps(fixture)) without_d1["charts"] = [chart for chart in without_d1["charts"] if chart["id"] != "D1"] result = validate_report_document(without_d1) assert result.valid is False assert any("exactly one D1" in error for error in result.errors) two_d1 = json.loads(json.dumps(fixture)) two_d1["charts"].append(json.loads(json.dumps(two_d1["charts"][0]))) result = validate_report_document(two_d1) assert result.valid is False assert any("duplicate chart id" in error for error in result.errors) assert any("exactly one D1" in error for error in result.errors) def test_d1_must_contain_all_twelve_houses(fixture: dict) -> None: incomplete = json.loads(json.dumps(fixture)) incomplete["charts"][0]["houses"] = incomplete["charts"][0]["houses"][:11] result = validate_report_document(incomplete) assert result.valid is False assert any("all twelve house numbers" in error for error in result.errors) def test_duplicate_house_numbers_rejected(fixture: dict) -> None: duplicated = json.loads(json.dumps(fixture)) duplicated["charts"][0]["houses"][11]["houseNumber"] = 1 result = validate_report_document(duplicated) assert result.valid is False assert any("duplicate houseNumber" in error for error in result.errors) def test_longitude_is_half_open_interval(fixture: dict) -> None: at_360 = json.loads(json.dumps(fixture)) at_360["charts"][0]["planets"][0]["longitudeDegrees"] = 360.0 result = validate_report_document(at_360) assert result.valid is False assert any("longitudeDegrees" in error for error in result.errors) near_360 = json.loads(json.dumps(fixture)) near_360["charts"][0]["planets"][0]["longitudeDegrees"] = 359.999 near_360["provenance"]["evidenceHash"] = compute_evidence_hash(near_360) assert validate_report_document(near_360).valid def test_evidence_ids_must_be_globally_unique(fixture: dict) -> None: duplicated = json.loads(json.dumps(fixture)) duplicated["evidenceAppendix"]["conflicts"][0]["id"] = duplicated["evidenceAppendix"]["techniqueAudit"][0]["id"] duplicated["provenance"]["evidenceHash"] = compute_evidence_hash(duplicated) result = validate_report_document(duplicated) assert result.valid is False assert any("duplicate evidence id" in error for error in result.errors) def test_dangling_evidence_refs_rejected(fixture: dict) -> None: dangling = json.loads(json.dumps(fixture)) dangling["thematicNarrative"][0]["evidenceRefs"] = ["ev-no-such-evidence"] result = validate_report_document(dangling) assert result.valid is False assert any("unknown evidence id" in error for error in result.errors) def test_evidence_hash_is_not_trusted_as_self_report(fixture: dict) -> None: tampered = json.loads(json.dumps(fixture)) tampered["evidenceAppendix"]["calculationEvidence"][0]["value"] = "篡改后的证据值" # Self-reported hash left unchanged: validator must recompute and reject. result = validate_report_document(tampered) assert result.valid is False assert any("does not match computed evidence hash" in error for error in result.errors) def test_blocked_sections_forbid_deterministic_predictions(fixture: dict) -> None: deterministic = json.loads(json.dumps(fixture)) deterministic["thematicNarrative"][3]["narrative"] = "这个事件必然会发生在明年,一定会成功。" result = validate_report_document(deterministic) assert result.valid is False assert any("blocked section contains deterministic prediction" in error for error in result.errors) non_deterministic = json.loads(json.dumps(fixture)) non_deterministic["thematicNarrative"][3]["narrative"] = "需要更多历史事件校准后才能评估,具体应期暂不提供。" non_deterministic["provenance"]["evidenceHash"] = compute_evidence_hash(non_deterministic) assert validate_report_document(non_deterministic).valid @pytest.mark.parametrize( "poison", [ "", "javascript:alert(1)", "file:///Users/jesse/private/chart.json", "参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}", "onerror=alert(1)", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc", "node:internal/modules/cjs/loader", "Traceback (most recent call last)", "__dirname/secret", "C:\\Users\\jesse\\chart.json", "tool_call_id: call_123", ], ) def test_forbidden_content_rejected(fixture: dict, poison: str) -> None: poisoned = json.loads(json.dumps(fixture)) poisoned["disclaimer"] = poison result = validate_report_document(poisoned) assert result.valid is False assert any("forbidden content" in error for error in result.errors) def test_serialization_size_cap(fixture: dict) -> None: oversized = json.loads(json.dumps(fixture)) oversized["disclaimer"] = "字" * (MAX_SERIALIZED_BYTES) result = validate_report_document(oversized) assert result.valid is False assert any("exceeding" in error for error in result.errors) def test_strict_keys_missing_and_extra(fixture: dict) -> None: missing = json.loads(json.dumps(fixture)) del missing["disclaimer"] result = validate_report_document(missing) assert result.valid is False assert any("missing required keys" in error for error in result.errors) extra = json.loads(json.dumps(fixture)) extra["disclaimer"] = extra["disclaimer"] extra["subject"]["hometown"] = "上海" result = validate_report_document(extra) assert result.valid is False assert any("unexpected keys" in error for error in result.errors) def test_json_object_key_order_is_not_validated(fixture: dict) -> None: # JSON objects are unordered; fixed reader order is a display contract of # the typed fields, never a key-order condition. reordered = {key: fixture[key] for key in reversed(list(fixture.keys()))} assert validate_report_document(reordered).valid def test_failure_code_enum_matches_both_migrations() -> None: for path in (SUPABASE_MIGRATION, LOCAL_MIGRATION): sql = path.read_text(encoding="utf-8") for code in FAILURE_CODES: assert f"'{code}'" in sql, f"{code} missing from {path.name}" assert sql.count("failure_code in") == 1 # Both migrations share the same stable enum. supabase_codes = set(FAILURE_CODES) local_sql = LOCAL_MIGRATION.read_text(encoding="utf-8") assert all(f"'{code}'" in local_sql for code in supabase_codes) def test_cli_exit_codes(fixture: dict) -> None: assert main([str(FIXTURE)]) == 0 assert main([str(ROOT / "scripts" / "personal_report_contract.py")]) == 1 assert main([]) == 2 assert main([str(ROOT / "does-not-exist.json")]) == 2 def test_validator_accepts_synthetic_producer_output() -> None: # A minimal-but-complete document produced without the fixture must pass. from scripts.personal_report_contract import ( BIRTH_TIME_STATUSES, CLAIM_STATUSES, PRESENTATION_MODES, REPORT_TYPES, SCHEMA_VERSION, ) houses = [ {"houseNumber": number, "sign": "狮子座", "occupants": []} for number in range(1, 13) ] document = { "schemaVersion": SCHEMA_VERSION, "reportId": "00000000-0000-4000-8000-000000000001", "reportType": REPORT_TYPES[0], "presentationMode": PRESENTATION_MODES[0], "generatedAt": "2026-08-06T08:00:00Z", "subject": { "displayName": "合成用户", "birthTimeStatus": BIRTH_TIME_STATUSES[0], "birthPlaceLabel": "合成地点", }, "provenance": { "skillName": "jyotish-vedic-astrology", "skillVersion": "6.9.14", "skillSourceCommit": None, "skillSnapshotSha256": "c" * 64, "calculationHash": "d" * 64, "evidenceHash": "0" * 64, "reportContractVersion": "1", }, "executiveSummary": { "headline": "摘要标题", "summary": "摘要正文。", "priorities": ["优先事项"], "overallClaimStatus": CLAIM_STATUSES[0], }, "charts": [{"id": "D1", "title": "本命盘", "houses": houses, "claimStatus": CLAIM_STATUSES[0]}], "thematicNarrative": [], "evidenceAppendix": { "expandedByDefault": False, "techniqueAudit": [ { "id": "ev-audit", "techniqueId": "d1_chart", "techniqueName": "D1 本命盘", "status": "verified", "used": True, } ], "conflicts": [], "calculationEvidence": [], "blockedTechniques": [], }, "disclaimer": "仅供研究参考。", } document["provenance"]["evidenceHash"] = compute_evidence_hash(document) assert validate_report_document(document).valid