Files

303 lines
13 KiB
Python
Raw Permalink Normal View History

"""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()