Files
yovision/tests/test_sense_control_contract.py
QiuSW b70df147ea
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
docs(contract): freeze Sense control API v1 [T-008]
2026-08-07 17:12:15 +08:00

303 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Repository-local invariants for the frozen Sense control-plane contract."""
from __future__ import annotations
import copy
import json
import re
import unittest
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
OPENAPI_PATH = ROOT / "docs" / "contracts" / "sense-control-v1.openapi.json"
QUOTA_SQL_PATH = ROOT / "docs" / "contracts" / "site-quota-v1.sql"
CONTRACT_README_PATH = ROOT / "docs" / "contracts" / "README.md"
HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head", "trace"}
EXPECTED_OPERATIONS = {
("get", "/api/v1/sites/{site_id}/devices"),
("post", "/api/v1/sites/{site_id}/devices"),
("get", "/api/v1/sites/{site_id}/devices/{device_id}"),
("patch", "/api/v1/sites/{site_id}/devices/{device_id}"),
("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"),
("post", "/api/v1/sites/{site_id}/devices:batchDesiredState"),
("get", "/api/v1/operations/{operation_id}"),
}
EXPECTED_ENUMS = {
"Modality": ["video", "radar", "contact", "button", "wearable", "other"],
"Capability": ["video_capture", "audio_capture", "spatial_rule", "telemetry"],
"DesiredState": ["disabled", "enabled"],
"ActualState": ["pending", "online", "offline", "failed"],
}
REQUIRED_ERROR_CODES = {
"invalid_request",
"unauthenticated",
"forbidden",
"not_found",
"precondition_required",
"etag_mismatch",
"idempotency_conflict",
"duplicate_serial_number",
"quota_exceeded",
"quota_projection_unavailable",
"quota_projection_invalid",
"area_policy_denied",
"area_policy_unavailable",
"adapter_not_ready",
"endpoint_credentials_forbidden",
"batch_too_large",
}
def load_openapi() -> dict[str, Any]:
return json.loads(OPENAPI_PATH.read_text(encoding="utf-8"))
def resolve_local_ref(document: dict[str, Any], value: dict[str, Any]) -> dict[str, Any]:
"""Resolve one local JSON pointer; the contract only needs direct component refs."""
ref = value.get("$ref")
if not ref:
return value
if not ref.startswith("#/"):
raise ValueError(f"external ref is not allowed here: {ref}")
current: Any = document
for token in ref[2:].split("/"):
current = current[token.replace("~1", "/").replace("~0", "~")]
if not isinstance(current, dict):
raise ValueError(f"ref does not resolve to an object: {ref}")
return current
def operation_parameters(
document: dict[str, Any], operation: dict[str, Any]
) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for value in operation.get("parameters", []):
parameter = resolve_local_ref(document, value)
result[parameter.get("name", "")] = parameter
return result
def validate_openapi(document: dict[str, Any]) -> list[str]:
errors: list[str] = []
if document.get("openapi") != "3.1.0":
errors.append("OpenAPI version must be 3.1.0")
if document.get("info", {}).get("version") != "1.0.0":
errors.append("contract version must be 1.0.0")
top_security = document.get("security")
if not top_security or not any("bearerAuth" in item for item in top_security):
errors.append("top-level bearerAuth is required")
operations: dict[tuple[str, str], dict[str, Any]] = {}
operation_ids: list[str] = []
for path, path_item in document.get("paths", {}).items():
if not path.startswith("/api/v1/"):
errors.append(f"unversioned path: {path}")
for method, operation in path_item.items():
if method not in HTTP_METHODS:
continue
key = (method, path)
operations[key] = operation
operation_id = operation.get("operationId")
if not operation_id:
errors.append(f"missing operationId: {method.upper()} {path}")
else:
operation_ids.append(operation_id)
if not operation.get("responses"):
errors.append(f"missing responses: {method.upper()} {path}")
effective_security = operation.get("security", top_security)
if not effective_security or not any(
"bearerAuth" in item for item in effective_security
):
errors.append(f"bearerAuth required: {method.upper()} {path}")
missing_operations = EXPECTED_OPERATIONS - set(operations)
unexpected_operations = set(operations) - EXPECTED_OPERATIONS
if missing_operations:
errors.append(f"missing operations: {sorted(missing_operations)}")
if unexpected_operations:
errors.append(f"unexpected operations: {sorted(unexpected_operations)}")
if len(operation_ids) != len(set(operation_ids)):
errors.append("operationId values must be unique")
list_operation = operations.get(("get", "/api/v1/sites/{site_id}/devices"), {})
list_parameters = operation_parameters(document, list_operation)
if "cursor" not in list_parameters:
errors.append("device list requires cursor pagination")
limit = list_parameters.get("limit", {}).get("schema", {})
if limit.get("default") != 50 or limit.get("maximum") != 100:
errors.append("device list limit must default to 50 and have maximum 100")
header_requirements = {
("post", "/api/v1/sites/{site_id}/devices"): "Idempotency-Key",
("post", "/api/v1/sites/{site_id}/devices:batchDesiredState"): "Idempotency-Key",
("patch", "/api/v1/sites/{site_id}/devices/{device_id}"): "If-Match",
("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"): "If-Match",
}
for key, header in header_requirements.items():
parameters = operation_parameters(document, operations.get(key, {}))
parameter = parameters.get(header, {})
if parameter.get("in") != "header" or parameter.get("required") is not True:
errors.append(f"{key[0].upper()} {key[1]} requires header {header}")
for key in (
("patch", "/api/v1/sites/{site_id}/devices/{device_id}"),
("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"),
):
responses = operations.get(key, {}).get("responses", {})
if "412" not in responses or "428" not in responses:
errors.append(f"{key[0].upper()} {key[1]} must define 412 and 428")
schemas = document.get("components", {}).get("schemas", {})
batch_items = (
schemas.get("BatchDesiredStateRequest", {})
.get("properties", {})
.get("items", {})
)
if batch_items.get("maxItems") != 128:
errors.append("batch desired-state request must have maximum 128 items")
client_schemas = ("DeviceCreate", "DevicePatch", "DesiredStateChange", "BatchDesiredStateRequest")
for schema_name in client_schemas:
if "tenant_id" in schemas.get(schema_name, {}).get("properties", {}):
errors.append(f"tenant_id must not be client-controlled in {schema_name}")
create_properties = schemas.get("DeviceCreate", {}).get("properties", {})
if "id" in create_properties:
errors.append("device id must be generated by the service")
required_create = set(schemas.get("DeviceCreate", {}).get("required", []))
if not {"modality", "capabilities"}.issubset(required_create):
errors.append("device create requires modality and capabilities")
for schema_name in ("DeviceCreate", "DevicePatch"):
properties = schemas.get(schema_name, {}).get("properties", {})
for field in ("endpoint_ref", "credential_ref", "profile_token"):
if properties.get(field, {}).get("writeOnly") is not True:
errors.append(f"{schema_name}.{field} must be writeOnly")
output_properties = schemas.get("Device", {}).get("properties", {})
tenant = output_properties.get("tenant_id", {})
if tenant.get("readOnly") is not True:
errors.append("Device.tenant_id must be readOnly")
forbidden_output_fields = {
"endpoint_ref",
"credential_ref",
"profile_token",
"active_profile",
"stream_uri",
"password",
"token",
}
leaked_fields = forbidden_output_fields & set(output_properties)
if leaked_fields:
errors.append(f"Device leaks sensitive fields: {sorted(leaked_fields)}")
for schema_name, expected in EXPECTED_ENUMS.items():
actual = schemas.get(schema_name, {}).get("enum")
if actual != expected:
errors.append(f"{schema_name} enum drift: {actual!r}")
error_codes = set(schemas.get("ErrorCode", {}).get("enum", []))
missing_error_codes = REQUIRED_ERROR_CODES - error_codes
if missing_error_codes:
errors.append(f"missing stable error codes: {sorted(missing_error_codes)}")
return errors
def validate_quota_sql(sql: str) -> list[str]:
errors: list[str] = []
normalized = re.sub(r"\s+", " ", sql.lower()).strip()
expected_signature = (
"create view bell.site_quota_v1 ( tenant_id, site_id, "
"max_video_channels, source_version, source_updated_at ) as"
)
if expected_signature not in normalized:
errors.append("bell.site_quota_v1 column signature is not frozen as expected")
if "alter view bell.site_quota_v1 owner to bell_app" not in normalized:
errors.append("Bell role must own the quota view")
if "grant usage on schema bell to sense_app" not in normalized:
errors.append("Sense role needs schema USAGE")
if "grant select on table bell.site_quota_v1 to sense_app" not in normalized:
errors.append("Sense role needs SELECT on only the quota view")
if "revoke all privileges on table bell.site_quota_v1 from public" not in normalized:
errors.append("PUBLIC privileges must be revoked")
if re.search(
r"grant\s+(?:all(?:\s+privileges)?|insert|update|delete|truncate|references|trigger)\b",
normalized,
):
errors.append("quota view grants a forbidden write or broad privilege")
if re.search(r"grant\s+select\s+on\s+table\s+bell\.sites\b", normalized):
errors.append("Sense must not receive SELECT on the Bell source table")
return errors
class SenseControlContractTests(unittest.TestCase):
def setUp(self) -> None:
self.document = load_openapi()
self.quota_sql = QUOTA_SQL_PATH.read_text(encoding="utf-8")
def test_frozen_openapi_invariants(self) -> None:
self.assertEqual([], validate_openapi(self.document))
def test_frozen_quota_projection_invariants(self) -> None:
self.assertEqual([], validate_quota_sql(self.quota_sql))
def test_contract_narrative_records_failure_and_compatibility_semantics(self) -> None:
text = CONTRACT_README_PATH.read_text(encoding="utf-8")
for marker in (
"默认 16",
"1~128",
"至少保存 24 小时",
"404 not_found",
"已有流保持运行",
"必须发布新版本",
):
self.assertIn(marker, text)
def test_validator_rejects_missing_authentication(self) -> None:
changed = copy.deepcopy(self.document)
changed.pop("security", None)
self.assertTrue(any("bearerAuth" in item for item in validate_openapi(changed)))
def test_validator_rejects_client_controlled_tenant(self) -> None:
changed = copy.deepcopy(self.document)
changed["components"]["schemas"]["DeviceCreate"]["properties"]["tenant_id"] = {
"type": "string"
}
self.assertTrue(any("tenant_id" in item for item in validate_openapi(changed)))
def test_validator_rejects_unbounded_list_or_batch(self) -> None:
changed = copy.deepcopy(self.document)
changed["components"]["parameters"]["Limit"]["schema"]["maximum"] = 101
changed["components"]["schemas"]["BatchDesiredStateRequest"]["properties"][
"items"
]["maxItems"] = 129
errors = validate_openapi(changed)
self.assertTrue(any("maximum 100" in item for item in errors))
self.assertTrue(any("maximum 128" in item for item in errors))
def test_validator_rejects_sensitive_output_or_writable_credential_ref(self) -> None:
changed = copy.deepcopy(self.document)
changed["components"]["schemas"]["DevicePatch"]["properties"]["credential_ref"][
"writeOnly"
] = False
changed["components"]["schemas"]["Device"]["properties"]["stream_uri"] = {
"type": "string"
}
errors = validate_openapi(changed)
self.assertTrue(any("credential_ref" in item for item in errors))
self.assertTrue(any("sensitive fields" in item for item in errors))
def test_validator_rejects_write_privilege(self) -> None:
changed = self.quota_sql + "\nGRANT ALL PRIVILEGES ON bell.site_quota_v1 TO sense_app;\n"
self.assertTrue(any("forbidden" in item for item in validate_quota_sql(changed)))
if __name__ == "__main__":
unittest.main()