feat(sense): implement Control API v1 [T-011]
This commit is contained in:
@@ -54,6 +54,8 @@ class PostgresContractTests(unittest.TestCase):
|
||||
"005_area_policy.sql",
|
||||
"006_device_operation_outbox.sql",
|
||||
"007_privileges_area_audit.sql",
|
||||
"008_control_api.sql",
|
||||
"009_privileges_control_api.sql",
|
||||
],
|
||||
names,
|
||||
)
|
||||
@@ -107,7 +109,7 @@ class PostgresContractTests(unittest.TestCase):
|
||||
"initdb.exe",
|
||||
"pg_ctl.exe",
|
||||
"127.0.0.1",
|
||||
"yovision-t010-pg-",
|
||||
"yovision-t011-pg-",
|
||||
"YOVISION_TEST_POSTGRES_DSN",
|
||||
"Get-NetTCPConnection",
|
||||
):
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Static safety checks for the T-011 Sense Control API implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def text(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def normalized(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value.lower())
|
||||
|
||||
|
||||
class SenseControlImplementationTests(unittest.TestCase):
|
||||
def test_generated_server_covers_all_frozen_operations(self) -> None:
|
||||
generated = text("Sense/internal/controlapi/generated.gen.go")
|
||||
self.assertIn("Code generated by github.com/oapi-codegen/oapi-codegen/v2", generated)
|
||||
for operation in (
|
||||
"ListDevices",
|
||||
"CreateDevice",
|
||||
"GetDevice",
|
||||
"UpdateDevice",
|
||||
"SetDeviceDesiredState",
|
||||
"BatchSetDeviceDesiredState",
|
||||
"GetOperation",
|
||||
):
|
||||
self.assertIn(operation, generated)
|
||||
config = text("Sense/internal/controlapi/oapi-codegen.yaml")
|
||||
self.assertIn("std-http-server: true", config)
|
||||
self.assertIn("./internal/controlapi", text("init.ps1"))
|
||||
|
||||
def test_control_api_is_feature_gated_to_postgres(self) -> None:
|
||||
config = text("Sense/internal/config/config.go")
|
||||
main = text("Sense/cmd/sense-api/main.go")
|
||||
self.assertIn('SENSE_CONTROL_API_ENABLED', config)
|
||||
self.assertIn('Control API requires SENSE_DB_DRIVER=postgres', config)
|
||||
self.assertIn('mux.Handle("/api/v1/", controlHandler)', main)
|
||||
self.assertNotIn('mux.Handle("/api/v1/"', text("Sense/internal/store/sqlite.go"))
|
||||
|
||||
def test_static_authentication_stores_only_digests(self) -> None:
|
||||
source = text("Sense/internal/auth/auth.go")
|
||||
example = json.loads(text("Sense/api/control-auth.example.json"))
|
||||
self.assertIn("subtle.ConstantTimeCompare", source)
|
||||
self.assertIn("sha256.Sum256([]byte(token))", source)
|
||||
self.assertNotIn('json:"token"', source)
|
||||
for principal in example["principals"]:
|
||||
self.assertRegex(principal["token_sha256"], r"^[0-9a-f]{64}$")
|
||||
self.assertNotIn("token", principal.keys() - {"token_sha256"})
|
||||
|
||||
def test_postgres_receipts_are_hashed_durable_and_at_least_24_hours(self) -> None:
|
||||
migration = normalized(text("deploy/postgres/008_control_api.sql"))
|
||||
implementation = text("Sense/internal/store/control_postgres.go")
|
||||
self.assertIn("create table if not exists sense.control_idempotency_receipts", migration)
|
||||
self.assertIn("scope_hash bytea primary key", migration)
|
||||
self.assertIn("expires_at >= created_at + interval '24 hours'", migration)
|
||||
self.assertNotIn("idempotency_key", migration)
|
||||
self.assertIn("pg_advisory_xact_lock", implementation)
|
||||
self.assertIn("ConstantTimeCompare", implementation)
|
||||
|
||||
def test_concurrency_batch_and_limits_are_not_hardcoded_to_16(self) -> None:
|
||||
migration = normalized(text("deploy/postgres/008_control_api.sql"))
|
||||
server = text("Sense/internal/controlapi/server.go")
|
||||
store = text("Sense/internal/store/control_postgres.go")
|
||||
self.assertIn("resource_version bigint not null default 1", migration)
|
||||
self.assertIn("len(body.Items) > 128", server)
|
||||
self.assertIn("limit > 100", server)
|
||||
self.assertIn("SAVEPOINT ", store)
|
||||
self.assertNotIn("len(body.Items) > 16", server)
|
||||
|
||||
def test_audit_v1_is_unchanged_and_v2_is_a_strict_superset(self) -> None:
|
||||
first = json.loads(text("docs/contracts/sense-device-audit-v1.schema.json"))
|
||||
second = json.loads(text("docs/contracts/sense-device-audit-v2.schema.json"))
|
||||
v1_events = set(first["properties"]["event_type"]["enum"])
|
||||
v2_events = set(second["properties"]["event_type"]["enum"])
|
||||
self.assertEqual(
|
||||
{"device.created", "device.desired_state.accepted"}, v1_events
|
||||
)
|
||||
self.assertEqual(v1_events | {"device.configuration.accepted"}, v2_events)
|
||||
self.assertIn("configurationData", second["$defs"])
|
||||
|
||||
def test_disabled_reconciliation_deletes_only_exact_path(self) -> None:
|
||||
reconciler = text("Sense/internal/reconcile/reconciler.go")
|
||||
media = text("Sense/internal/mtx/client.go")
|
||||
self.assertIn("candidate.Device.DesiredState == device.DesiredDisabled", reconciler)
|
||||
self.assertIn("r.media.DeletePath(ctx, candidate.Device.PathName)", reconciler)
|
||||
self.assertIn("Deletion is an idempotent convergence operation", media)
|
||||
self.assertNotIn("ListPaths", reconciler)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user