262 lines
11 KiB
Python
262 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import unittest
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import rfc8785
|
|
from jsonschema import Draft202012Validator, FormatChecker
|
|
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
|
CONTRACT_ROOT = REPOSITORY_ROOT / "contracts" / "source-config" / "v1"
|
|
SCHEMA_PATH = CONTRACT_ROOT / "source-config.schema.json"
|
|
VALID_ROOT = CONTRACT_ROOT / "examples" / "valid"
|
|
INVALID_ROOT = CONTRACT_ROOT / "examples" / "invalid"
|
|
FORBIDDEN_KEY = re.compile(r"(?:credential|password|secret|token|username|cookie|jwt)", re.IGNORECASE)
|
|
FORBIDDEN_MEDIA_CHARACTER = re.compile(r"[?@#\\]")
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
value = json.load(handle)
|
|
if not isinstance(value, dict):
|
|
raise AssertionError(f"{path} must contain a JSON object")
|
|
return value
|
|
|
|
|
|
SCHEMA = load_json(SCHEMA_PATH)
|
|
VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
|
|
|
|
|
|
def integrity_value(payload: dict[str, Any]) -> str:
|
|
content = copy.deepcopy(payload)
|
|
content.pop("integrity", None)
|
|
return hashlib.sha256(rfc8785.dumps(content)).hexdigest()
|
|
|
|
|
|
def set_integrity(payload: dict[str, Any]) -> None:
|
|
payload["integrity"] = {"algorithm": "sha256", "value": integrity_value(payload)}
|
|
|
|
|
|
def reject_secrets(value: Any, path: str = "config") -> None:
|
|
if isinstance(value, dict):
|
|
for key, child in value.items():
|
|
if FORBIDDEN_KEY.search(str(key)):
|
|
raise ValueError(f"secret field is forbidden at {path}.{key}")
|
|
reject_secrets(child, f"{path}.{key}")
|
|
elif isinstance(value, list):
|
|
for index, child in enumerate(value):
|
|
reject_secrets(child, f"{path}[{index}]")
|
|
|
|
|
|
def polygon_area(points: list[dict[str, float]]) -> float:
|
|
return abs(
|
|
sum(
|
|
point["x"] * points[(index + 1) % len(points)]["y"]
|
|
- points[(index + 1) % len(points)]["x"] * point["y"]
|
|
for index, point in enumerate(points)
|
|
)
|
|
/ 2
|
|
)
|
|
|
|
|
|
def validate_payload(payload: dict[str, Any]) -> None:
|
|
if payload.get("schema_version") != "yovision.source-config/v1":
|
|
raise ValueError("unknown schema major version")
|
|
|
|
reject_secrets(payload)
|
|
media_ref = str(payload.get("media", {}).get("ref", ""))
|
|
if (
|
|
FORBIDDEN_MEDIA_CHARACTER.search(media_ref)
|
|
or "://" in media_ref
|
|
or re.match(r"^[A-Za-z]:", media_ref)
|
|
):
|
|
raise ValueError("secret, query, authority, or internal path in media reference")
|
|
|
|
errors = sorted(VALIDATOR.iter_errors(payload), key=lambda error: list(error.absolute_path))
|
|
if errors:
|
|
first = errors[0]
|
|
location = ".".join(str(part) for part in first.absolute_path) or "config"
|
|
raise ValueError(f"schema validation failed at {location}: {first.message}")
|
|
|
|
profile = payload["profile"]
|
|
binding = payload["rule_set"]["profile_binding"]
|
|
if (binding["profile_id"], binding["width"], binding["height"]) != (
|
|
profile["id"],
|
|
profile["width"],
|
|
profile["height"],
|
|
):
|
|
raise ValueError("profile binding does not match the media profile")
|
|
|
|
published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00"))
|
|
effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00"))
|
|
if effective_at < published_at:
|
|
raise ValueError("effective_at precedes published_at")
|
|
|
|
rule_set = payload["rule_set"]
|
|
rules = [*rule_set["areas"], *rule_set["directional_lines"]]
|
|
identifiers = [rule["id"] for rule in rules]
|
|
if len(identifiers) != len(set(identifiers)):
|
|
raise ValueError("rule ids must be unique across the rule set")
|
|
if rule_set["state"] == "recalibration_required" and any(rule["enabled"] for rule in rules):
|
|
raise ValueError("recalibration-required rules must not remain enabled")
|
|
|
|
for area in rule_set["areas"]:
|
|
if polygon_area(area["points"]) <= 1e-12:
|
|
raise ValueError(f"area {area['id']} is a degenerate polygon")
|
|
for line in rule_set["directional_lines"]:
|
|
if line["start"] == line["end"]:
|
|
raise ValueError(f"directional line {line['id']} has identical endpoints")
|
|
|
|
if payload["integrity"]["value"] != integrity_value(payload):
|
|
raise ValueError("integrity digest mismatch")
|
|
|
|
|
|
def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None:
|
|
validate_payload(previous)
|
|
validate_payload(current)
|
|
if previous["config_id"] != current["config_id"]:
|
|
raise ValueError("config_id cannot change within one revision stream")
|
|
if current["revision"] <= previous["revision"]:
|
|
raise ValueError("revision must increase strictly")
|
|
|
|
previous_profile = previous["profile"]
|
|
current_profile = current["profile"]
|
|
profile_changed = any(
|
|
previous_profile[field] != current_profile[field]
|
|
for field in ("id", "width", "height", "encoding")
|
|
)
|
|
previous_rule_versions = sorted(
|
|
(rule["id"], rule["version"])
|
|
for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]]
|
|
)
|
|
current_rule_versions = sorted(
|
|
(rule["id"], rule["version"])
|
|
for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]]
|
|
)
|
|
if (
|
|
profile_changed
|
|
and previous_rule_versions == current_rule_versions
|
|
and current["rule_set"]["state"] != "recalibration_required"
|
|
):
|
|
raise ValueError("profile changed without rule recalibration state or new rule versions")
|
|
|
|
|
|
class SourceConfigV1ContractTests(unittest.TestCase):
|
|
def test_schema_is_valid_draft_2020_12(self) -> None:
|
|
Draft202012Validator.check_schema(SCHEMA)
|
|
|
|
def test_all_valid_examples_pass_schema_semantics_and_integrity(self) -> None:
|
|
examples = sorted(VALID_ROOT.glob("*.json"))
|
|
self.assertGreaterEqual(len(examples), 2)
|
|
for path in examples:
|
|
with self.subTest(path=path.name):
|
|
validate_payload(load_json(path))
|
|
|
|
def test_invalid_examples_fail_for_the_declared_reason(self) -> None:
|
|
expected = load_json(INVALID_ROOT / "expected-errors.json")
|
|
self.assertGreaterEqual(len(expected), 6)
|
|
for filename, reason in expected.items():
|
|
with self.subTest(path=filename):
|
|
with self.assertRaisesRegex(ValueError, str(reason)):
|
|
validate_payload(load_json(INVALID_ROOT / filename))
|
|
|
|
def test_tampering_is_detected_after_other_validation(self) -> None:
|
|
payload = load_json(VALID_ROOT / "active.json")
|
|
payload["revision"] += 1
|
|
with self.assertRaisesRegex(ValueError, "integrity digest mismatch"):
|
|
validate_payload(payload)
|
|
|
|
def test_namespaced_optional_extensions_are_compatible_but_not_secret_bearing(self) -> None:
|
|
payload = load_json(VALID_ROOT / "active.json")
|
|
payload["extensions"] = {"example.analytics": {"samplingHint": "balanced"}}
|
|
set_integrity(payload)
|
|
validate_payload(payload)
|
|
|
|
payload["extensions"] = {"example.analytics": {"accessToken": "forbidden"}}
|
|
set_integrity(payload)
|
|
with self.assertRaisesRegex(ValueError, "secret field"):
|
|
validate_payload(payload)
|
|
|
|
def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None:
|
|
payload = load_json(VALID_ROOT / "active.json")
|
|
payload["rule_set"]["profile_binding"]["width"] = 1280
|
|
set_integrity(payload)
|
|
with self.assertRaisesRegex(ValueError, "profile binding"):
|
|
validate_payload(payload)
|
|
|
|
payload = load_json(VALID_ROOT / "active.json")
|
|
payload["rule_set"]["state"] = "recalibration_required"
|
|
set_integrity(payload)
|
|
with self.assertRaisesRegex(ValueError, "must not remain enabled"):
|
|
validate_payload(payload)
|
|
|
|
def test_revision_stream_rejects_stale_and_unrecalibrated_profile_change(self) -> None:
|
|
previous = load_json(VALID_ROOT / "active.json")
|
|
current = copy.deepcopy(previous)
|
|
current["revision"] = previous["revision"]
|
|
set_integrity(current)
|
|
with self.assertRaisesRegex(ValueError, "revision must increase"):
|
|
validate_transition(previous, current)
|
|
|
|
current["revision"] += 1
|
|
current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720})
|
|
current["rule_set"]["profile_binding"].update(
|
|
{"profile_id": "main-stream-v2", "width": 1280, "height": 720}
|
|
)
|
|
set_integrity(current)
|
|
with self.assertRaisesRegex(ValueError, "without rule recalibration"):
|
|
validate_transition(previous, current)
|
|
|
|
validate_transition(previous, load_json(VALID_ROOT / "recalibration-required.json"))
|
|
|
|
def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None:
|
|
payload = load_json(VALID_ROOT / "active.json")
|
|
payload["rule_set"]["areas"][0]["points"] = [
|
|
{"x": 0, "y": 0},
|
|
{"x": 0.5, "y": 0.5},
|
|
{"x": 1, "y": 1},
|
|
]
|
|
set_integrity(payload)
|
|
with self.assertRaisesRegex(ValueError, "degenerate polygon"):
|
|
validate_payload(payload)
|
|
|
|
payload = load_json(VALID_ROOT / "active.json")
|
|
payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"]
|
|
set_integrity(payload)
|
|
with self.assertRaisesRegex(ValueError, "ids must be unique"):
|
|
validate_payload(payload)
|
|
|
|
def test_effective_time_cannot_precede_publication(self) -> None:
|
|
payload = load_json(VALID_ROOT / "active.json")
|
|
payload["effective_at"] = "2026-08-30T23:59:59Z"
|
|
set_integrity(payload)
|
|
with self.assertRaisesRegex(ValueError, "precedes"):
|
|
validate_payload(payload)
|
|
|
|
def test_shared_payload_does_not_claim_either_product_internal_model(self) -> None:
|
|
for path in sorted(VALID_ROOT.glob("*.json")):
|
|
serialized = json.dumps(load_json(path), ensure_ascii=False).lower()
|
|
self.assertNotIn("brain.internal.input", serialized)
|
|
self.assertNotIn("streamuri", serialized)
|
|
self.assertNotIn("profiletoken", serialized)
|
|
self.assertNotIn("database", serialized)
|
|
self.assertNotRegex(serialized, r"[a-z]:\\")
|
|
|
|
def test_mapper_documents_both_product_test_responsibilities(self) -> None:
|
|
mapper = (CONTRACT_ROOT / "mapper-fields.md").read_text(encoding="utf-8")
|
|
self.assertIn("Sense 生产者契约测试", mapper)
|
|
self.assertIn("Brain 消费者契约测试", mapper)
|
|
self.assertIn("brain.internal.input/v1", mapper)
|
|
self.assertIn("admissionProfile.StreamURI", mapper)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|