319 lines
13 KiB
Python
319 lines
13 KiB
Python
"""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",
|
|
[
|
|
"<script>alert(1)</script>",
|
|
"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
|