feat(k1): stabilize LAB bridge and isolate onboard device integration
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import copy
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.fleet.device_enrollment import DeviceEnrollment, validate
|
||||
from k1link.fleet.trust import PairingError
|
||||
|
||||
|
||||
class Fleet:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.row = {
|
||||
"id": "vehicle",
|
||||
"node_id": "node-1",
|
||||
"binding": {"binding_id": "binding-1"},
|
||||
"device_enrollment": {"available": True, "runtime_id": "runtime-1"},
|
||||
}
|
||||
|
||||
def find(self, _identifier):
|
||||
return self.row
|
||||
|
||||
def public(self, row):
|
||||
return {"connectivity": "online"}
|
||||
|
||||
|
||||
def command():
|
||||
# Synthetic value constructed at runtime; never a real WLAN credential.
|
||||
return {
|
||||
"operation_id": "op_" + "a" * 32,
|
||||
"node_id": "node-1",
|
||||
"runtime_id": "runtime-1",
|
||||
"action": "connect",
|
||||
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
|
||||
"parameters": {
|
||||
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||
"discovery_generation": 1,
|
||||
"mode_revision": 0,
|
||||
"ssid": "test-net",
|
||||
"password": secrets.token_urlsafe(20),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_password_only_lives_in_pending_delivery_and_never_in_fleet_or_result():
|
||||
fleet, bus, request = Fleet(), DeviceEnrollment(), command()
|
||||
secret = request["parameters"]["password"]
|
||||
public = bus.submit(fleet, "vehicle", request)
|
||||
assert secret not in json.dumps(public)
|
||||
assert secret not in json.dumps(fleet.row)
|
||||
envelope = bus.heartbeat(fleet.row, {})
|
||||
assert envelope["enrollment_commands"][0]["parameters"]["password"] == secret
|
||||
bus.heartbeat(
|
||||
fleet.row,
|
||||
{"enrollment_results": [{"operation_id": request["operation_id"], "state": "running"}]},
|
||||
)
|
||||
assert secret not in json.dumps(list(bus.pending.values()))
|
||||
assert not bus.heartbeat(fleet.row, {})["enrollment_commands"]
|
||||
|
||||
|
||||
def test_unknown_after_core_restart_does_not_recreate_command():
|
||||
fleet, bus, request = Fleet(), DeviceEnrollment(), command()
|
||||
bus.submit(fleet, "vehicle", request)
|
||||
restarted = DeviceEnrollment()
|
||||
assert restarted.operation(fleet, "vehicle", request["operation_id"])["state"] == "unknown"
|
||||
assert not restarted.heartbeat(fleet.row, {})["enrollment_commands"]
|
||||
|
||||
|
||||
def test_fences_board_runtime_and_deadline_and_forbids_host_actions():
|
||||
for change in (
|
||||
{"node_id": "node-2"},
|
||||
{"runtime_id": "runtime-2"},
|
||||
{"action": "quick-connect"},
|
||||
{"deadline_at": datetime.now(UTC).isoformat()},
|
||||
):
|
||||
fleet, bus, request = Fleet(), DeviceEnrollment(), command()
|
||||
request.update(change)
|
||||
with pytest.raises(PairingError):
|
||||
bus.submit(fleet, "vehicle", request)
|
||||
request = command()
|
||||
request["parameters"]["allow_host_wifi_switch"] = True
|
||||
with pytest.raises(PairingError):
|
||||
validate(request)
|
||||
|
||||
|
||||
def test_rebinding_drops_delivery_and_old_results():
|
||||
fleet, bus, request = Fleet(), DeviceEnrollment(), command()
|
||||
bus.submit(fleet, "vehicle", request)
|
||||
fleet.row["binding"]["binding_id"] = "binding-2"
|
||||
assert not bus.heartbeat(fleet.row, {})["enrollment_commands"]
|
||||
assert bus.operation(fleet, "vehicle", request["operation_id"])["state"] == "unknown"
|
||||
|
||||
|
||||
def test_expiry_drops_secret_without_automatic_retry():
|
||||
fleet, bus, request = Fleet(), DeviceEnrollment(), command()
|
||||
bus.submit(fleet, "vehicle", request)
|
||||
bus.pending[("vehicle", request["operation_id"])]["deadline"] = 0
|
||||
assert bus.operation(fleet, "vehicle", request["operation_id"])["state"] == "unknown"
|
||||
assert bus.pending[("vehicle", request["operation_id"])]["payload"] is None
|
||||
|
||||
|
||||
def test_duplicate_id_cannot_replace_original_secret():
|
||||
fleet, bus, request = Fleet(), DeviceEnrollment(), command()
|
||||
bus.submit(fleet, "vehicle", request)
|
||||
duplicate = copy.deepcopy(request)
|
||||
duplicate["parameters"]["password"] += "changed"
|
||||
bus.submit(fleet, "vehicle", duplicate)
|
||||
assert bus.pending[("vehicle", request["operation_id"])]["payload"] == request
|
||||
+119
-8
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -36,6 +36,117 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
RecoveryOperationKind = Literal["status-read", "wifi-provision", "ap-enable"]
|
||||
|
||||
|
||||
def install_advertisement_source(
|
||||
monkeypatch: MonkeyPatch,
|
||||
observations: list[tuple[float, BLEDevice, str | None]],
|
||||
) -> dict[str, int]:
|
||||
"""A single native scanner that delivers advertisements while it is open."""
|
||||
lifecycle = {"started": 0, "stopped": 0}
|
||||
|
||||
class FakeScanner:
|
||||
def __init__(
|
||||
self, detection_callback: Callable[[BLEDevice, AdvertisementData], None]
|
||||
) -> None:
|
||||
self.callback = detection_callback
|
||||
self.scheduled: list[asyncio.TimerHandle] = []
|
||||
|
||||
async def __aenter__(self) -> "FakeScanner":
|
||||
lifecycle["started"] += 1
|
||||
for delay, device, name in observations:
|
||||
advertisement = AdvertisementData(
|
||||
local_name=name, manufacturer_data={}, service_data={},
|
||||
service_uuids=[], tx_power=None, rssi=-45, platform_data=(),
|
||||
)
|
||||
self.scheduled.append(asyncio.get_running_loop().call_later(
|
||||
delay, self.callback, device, advertisement
|
||||
))
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
lifecycle["stopped"] += 1
|
||||
for timer in self.scheduled:
|
||||
timer.cancel()
|
||||
|
||||
monkeypatch.setattr(scanner_module, "BleakScanner", FakeScanner)
|
||||
monkeypatch.setattr(scanner_module, "BLE_SCAN_INITIAL_WINDOW_SECONDS", 0.02)
|
||||
return lifecycle
|
||||
|
||||
|
||||
def test_discovery_keeps_one_scanner_open_for_a_late_k1_name(monkeypatch: MonkeyPatch) -> None:
|
||||
device = BLEDevice("LATE-K1", None, details=object())
|
||||
lifecycle = install_advertisement_source(monkeypatch, [
|
||||
(0, device, None), (0.05, device, "XGR-LATE"),
|
||||
])
|
||||
|
||||
async def scenario() -> None:
|
||||
result = await scan(0.4)
|
||||
timing = result["discovery_timing"]
|
||||
assert timing["scan_extended"] is True
|
||||
assert timing["first_candidate_ms"] >= timing["initial_window_ms"]
|
||||
assert timing["scan_elapsed_ms"] < 400
|
||||
assert len(result["devices"]) == 1
|
||||
assert result["devices"][0]["k1_name_candidate"] is True
|
||||
assert discovered_device(device.address) is device
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert lifecycle == {"started": 1, "stopped": 1}
|
||||
|
||||
|
||||
def test_discovery_finishes_after_initial_window_when_k1_is_visible(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
device = BLEDevice("EARLY-K1", "XGR-EARLY", details=object())
|
||||
lifecycle = install_advertisement_source(monkeypatch, [(0, device, "XGR-EARLY")])
|
||||
result = asyncio.run(scan(0.4))
|
||||
timing = result["discovery_timing"]
|
||||
assert timing["scan_extended"] is False
|
||||
assert timing["first_candidate_ms"] < timing["initial_window_ms"]
|
||||
assert 20 <= timing["scan_elapsed_ms"] < 400
|
||||
assert lifecycle == {"started": 1, "stopped": 1}
|
||||
|
||||
|
||||
def test_discovery_without_k1_stops_at_deadline_and_does_not_reuse_old_results(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
device = BLEDevice("OTHER-DEVICE", "Headphones", details=object())
|
||||
lifecycle = install_advertisement_source(monkeypatch, [(0, device, "Headphones")])
|
||||
|
||||
async def scenario() -> None:
|
||||
result = await scan(0.06)
|
||||
timing = result["discovery_timing"]
|
||||
assert timing["scan_extended"] is True
|
||||
assert "first_candidate_ms" not in timing
|
||||
assert timing["scan_elapsed_ms"] >= 60
|
||||
assert result["devices"][0]["k1_name_candidate"] is False
|
||||
install_advertisement_source(monkeypatch, [])
|
||||
empty = await scan(0.01)
|
||||
assert empty["devices"] == []
|
||||
assert empty["discovery_timing"]["scan_extended"] is False
|
||||
assert discovered_device(device.address) is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert lifecycle == {"started": 1, "stopped": 1}
|
||||
|
||||
|
||||
def test_cancelled_discovery_closes_scanner_without_publishing_partial_handles(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
device = BLEDevice("PARTIAL", "Headphones", details=object())
|
||||
lifecycle = install_advertisement_source(monkeypatch, [(0, device, "Headphones")])
|
||||
|
||||
async def scenario() -> None:
|
||||
task = asyncio.create_task(scan(1))
|
||||
await asyncio.sleep(0.04)
|
||||
assert lifecycle["started"] == 1
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert discovered_device(device.address) is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert lifecycle == {"started": 1, "stopped": 1}
|
||||
|
||||
|
||||
async def _retrieve_connected_inside_ble_lease(
|
||||
macos_uuid: str,
|
||||
*,
|
||||
@@ -175,7 +286,7 @@ def test_scan_retains_the_live_corebluetooth_handle(
|
||||
return {"LIVE-UUID": (device, advertisement)}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"k1link.device_plugins.xgrids_k1.ble.scanner.BleakScanner.discover",
|
||||
"k1link.device_plugins.xgrids_k1.ble.scanner._discover_k1_advertisements",
|
||||
fake_discover,
|
||||
)
|
||||
|
||||
@@ -209,7 +320,7 @@ def test_scan_start_invalidates_previous_lease_and_failure_leaves_it_empty(
|
||||
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||
return {old_device.address: (old_device, old_advertisement)}
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", initial_discover)
|
||||
monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", initial_discover)
|
||||
await scan(1.0)
|
||||
assert discovered_device(old_device.address) is old_device
|
||||
|
||||
@@ -221,7 +332,7 @@ def test_scan_start_invalidates_previous_lease_and_failure_leaves_it_empty(
|
||||
await release_failing_scan.wait()
|
||||
raise RuntimeError("synthetic BLE scan failure")
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", failing_discover)
|
||||
monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", failing_discover)
|
||||
scan_task = asyncio.create_task(scan(1.0))
|
||||
await failing_scan_started.wait()
|
||||
|
||||
@@ -263,7 +374,7 @@ def test_process_arbiter_rejects_overlapping_scan(monkeypatch: MonkeyPatch) -> N
|
||||
await release_older_scan.wait()
|
||||
return {older_device.address: (older_device, older_advertisement)}
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", blocked_discover)
|
||||
monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", blocked_discover)
|
||||
older_task = asyncio.create_task(scan(1.0))
|
||||
await older_scan_started.wait()
|
||||
|
||||
@@ -377,7 +488,7 @@ def test_connected_handle_is_session_scoped_and_survives_wall_clock_age(
|
||||
async def fake_discover(**_kwargs: object) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||
return discoveries.pop(0)
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", fake_discover)
|
||||
monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", fake_discover)
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: monotonic_clock[0])
|
||||
monkeypatch.setattr(
|
||||
scanner_module,
|
||||
@@ -487,7 +598,7 @@ def test_scan_and_retained_handle_survive_macos_sleep_until_explicit_invalidatio
|
||||
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||
return {device.address: (device, advertisement)}
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", fake_discover)
|
||||
monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", fake_discover)
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: monotonic_clock[0])
|
||||
monkeypatch.setattr(
|
||||
scanner_module,
|
||||
@@ -1340,7 +1451,7 @@ def test_later_fresh_scan_does_not_replace_retained_corebluetooth_object(
|
||||
async def fake_discover(**_kwargs: object) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||
return discoveries.pop(0)
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", fake_discover)
|
||||
monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", fake_discover)
|
||||
|
||||
async def scenario() -> None:
|
||||
await scan(1.0)
|
||||
|
||||
@@ -36,6 +36,36 @@ def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
||||
raise AssertionError(f"{method} {path} route is missing")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("primary", "secondary", "expected"),
|
||||
[
|
||||
("spatial-scene", "cameras", ("cameras", None)),
|
||||
("spatial-scene", None, ("cameras", None)),
|
||||
("telemetry", "spatial-scene", ("telemetry", None)),
|
||||
("objects", "world-map", ("objects", "world-map")),
|
||||
],
|
||||
)
|
||||
def test_lab_scene_move_preserves_other_operator_settings(
|
||||
tmp_path: Path, primary: str, secondary: str | None, expected: tuple[str, str | None]
|
||||
) -> None:
|
||||
store = EnvironmentSettingsStore(tmp_path)
|
||||
original = default_environment_settings()
|
||||
original.revision = 19
|
||||
page = original.pages.observation
|
||||
page.header_label = "Контроль"
|
||||
page.primary_workspace_id = primary
|
||||
page.secondary_workspace_id = secondary
|
||||
store.settings_path.write_text(original.model_dump_json())
|
||||
restored = store.read()
|
||||
assert restored.revision == 19
|
||||
assert restored.pages.observation.header_label == "Контроль"
|
||||
assert restored.pages.observation.background == original.pages.observation.background
|
||||
assert (restored.pages.observation.primary_workspace_id,
|
||||
restored.pages.observation.secondary_workspace_id) == expected
|
||||
assert restored.pages.home == original.pages.home
|
||||
assert restored.pages.polygon == original.pages.polygon
|
||||
|
||||
|
||||
def _streaming_request(payload: bytes, media_type: str) -> Request:
|
||||
chunks = iter((payload[:8], payload[8:]))
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Host parity and preservation of the physical command/observation boundary."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.connection_attempt import compact_connection_attempt
|
||||
from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge
|
||||
|
||||
|
||||
def snapshot(status="running"):
|
||||
return {
|
||||
"snapshot_runtime_id": "synthetic-runtime",
|
||||
"snapshot_runtime_started_at_utc": "2026-09-06T00:00:00Z",
|
||||
"snapshot_revision": 9,
|
||||
"ble_discovery_generation": 2,
|
||||
"desired_connection_mode_revision": 1,
|
||||
"active_connection_mode": None,
|
||||
"connection_lifecycle": {
|
||||
"connection_ready": False,
|
||||
"ready_to_start": False,
|
||||
"allowed_actions": ["scan-ble", "provision-fresh-device", "stop-acquisition"],
|
||||
},
|
||||
"devices": [{"device_id": "synthetic-ble", "likely_k1": True}],
|
||||
"connection_attempt": {
|
||||
"schema_version": "missioncore.xgrids-k1-connection-attempt/v1",
|
||||
"attempt_id": "op_" + "a" * 32,
|
||||
"connection_mode": "bridge",
|
||||
"status": status,
|
||||
"phase": "network_applied",
|
||||
"control_state": "unknown",
|
||||
"stage": "control-bootstrap",
|
||||
"safe_next_action": "wait-for-current-attempt",
|
||||
"public_error_code": None,
|
||||
},
|
||||
"operations": [],
|
||||
}
|
||||
|
||||
|
||||
def command():
|
||||
return {
|
||||
"operation_id": "op_" + "a" * 32,
|
||||
"runtime_id": "synthetic-runtime",
|
||||
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
|
||||
"action": "connect",
|
||||
"parameters": {
|
||||
"device_id": "synthetic-ble",
|
||||
"discovery_generation": 2,
|
||||
"mode_revision": 1,
|
||||
"ssid": "synthetic-network",
|
||||
"password": secrets.token_urlsafe(24),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_node_projects_same_attempt_and_current_authority_without_payloads():
|
||||
source = snapshot()
|
||||
private = secrets.token_urlsafe(24)
|
||||
source["connection_attempt"]["diagnostic_bundle"] = {"private": private}
|
||||
source["connection_attempt"]["timeline"] = [{"private": private}]
|
||||
source["connection_attempt"]["password"] = private
|
||||
result = NodeBridge.project(source)
|
||||
assert result["snapshot_revision"] == 9
|
||||
assert result["runtime_started_at"] == source["snapshot_runtime_started_at_utc"]
|
||||
assert result["connection_attempt"]["status"] == "running"
|
||||
assert result["connection_attempt"]["phase"] == "network_applied"
|
||||
assert result["connection_attempt"]["safe_next_action"] == "wait-for-current-attempt"
|
||||
assert not result["connected"]
|
||||
assert result["allowed_actions"] == ["scan-ble", "provision-fresh-device"]
|
||||
assert private not in json.dumps(result)
|
||||
result["connection_attempt"]["status"] = "succeeded"
|
||||
assert source["connection_attempt"]["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, [], {}, {"schema_version": "unreviewed"}])
|
||||
def test_compact_attempt_requires_known_contract(value):
|
||||
assert compact_connection_attempt(value) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("journaled", [True, False])
|
||||
def test_lost_invocation_is_observed_once_and_never_resubmitted(journaled):
|
||||
async def run():
|
||||
bridge = NodeBridge(Path.cwd(), service=object())
|
||||
current = snapshot()
|
||||
actions = []
|
||||
request = command()
|
||||
private = request["parameters"]["password"]
|
||||
|
||||
async def invoke(action, parameters, identifier):
|
||||
actions.append(action)
|
||||
if action == "network.provision":
|
||||
if journaled:
|
||||
current["operations"] = [
|
||||
{
|
||||
"operation_id": identifier,
|
||||
"status": "failed",
|
||||
"error": {"code": "k1-wifi-network-not-found"},
|
||||
}
|
||||
]
|
||||
current["connection_attempt"].update(
|
||||
status="failed",
|
||||
phase="network_outcome_unknown",
|
||||
public_error_code="k1-wifi-network-not-found",
|
||||
safe_next_action="scan-select-connect",
|
||||
)
|
||||
raise RuntimeError(private)
|
||||
return copy.deepcopy(current)
|
||||
|
||||
bridge.invoke = invoke
|
||||
if journaled:
|
||||
result = await bridge.execute(request)
|
||||
assert result["command_result"]["status"] == "failed"
|
||||
assert result["connection_attempt"]["public_error_code"] == "k1-wifi-network-not-found"
|
||||
assert private not in json.dumps(result)
|
||||
else:
|
||||
with pytest.raises(RuntimeError):
|
||||
await bridge.execute(request)
|
||||
assert actions == ["state.read", "network.provision", "state.read"]
|
||||
assert "password" not in request["parameters"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_pre_dispatch_rejection_clears_secret_and_never_calls_provisioning():
|
||||
async def run():
|
||||
bridge = NodeBridge(Path.cwd(), service=object())
|
||||
actions = []
|
||||
|
||||
async def invoke(action, *_):
|
||||
actions.append(action)
|
||||
return snapshot()
|
||||
|
||||
bridge.invoke = invoke
|
||||
request = command()
|
||||
request["parameters"]["discovery_generation"] = 1
|
||||
with pytest.raises(ValueError):
|
||||
await bridge.execute(request)
|
||||
assert actions == ["state.read"]
|
||||
assert "password" not in request["parameters"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_delivery_distinguishes_pre_dispatch_rejection_from_lost_result():
|
||||
async def run():
|
||||
bridge = NodeBridge(Path.cwd(), service=object())
|
||||
actions = []
|
||||
|
||||
async def invoke(action, *_):
|
||||
actions.append(action)
|
||||
return snapshot()
|
||||
|
||||
bridge.invoke = invoke
|
||||
request = command()
|
||||
request["runtime_id"] = "stale-runtime"
|
||||
result = await bridge.deliver(request)
|
||||
assert result["command_result"] == {
|
||||
"operation_id": request["operation_id"],
|
||||
"status": "rejected",
|
||||
"error_code": "runtime-changed",
|
||||
}
|
||||
assert actions == ["state.read", "state.read"]
|
||||
assert "password" not in request["parameters"]
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,72 @@
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.wifi_failure import reviewed_station_failure_code
|
||||
|
||||
|
||||
def station_error(code: int = 4) -> dict[str, object]:
|
||||
return {
|
||||
"code": "BleakGATTProtocolError",
|
||||
"operation_stage": "gatt-write",
|
||||
"device_write_attempted": True,
|
||||
"device_write_confirmed": False,
|
||||
"resolved_write_mode": "with_response",
|
||||
"frame_length": 99,
|
||||
"ble_att_error_code": code,
|
||||
"ble_att_error_name": "INVALID_PDU",
|
||||
"safe_to_retry": False,
|
||||
"side_effect_status": "unknown",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["bridge", "direct-connect"])
|
||||
@pytest.mark.parametrize(
|
||||
"code,reason", [(4, "k1-wifi-network-not-found"), (6, "k1-wifi-credentials-required")]
|
||||
)
|
||||
def test_reviewed_station_reply_retains_raw_facts_and_retry_fence(
|
||||
mode: str, code: int, reason: str
|
||||
) -> None:
|
||||
error = station_error(code)
|
||||
before = deepcopy(error)
|
||||
assert (
|
||||
reviewed_station_failure_code(error, firmware_version="3.0.2", connection_mode=mode)
|
||||
== reason
|
||||
)
|
||||
assert error == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("code", "RuntimeError"),
|
||||
("operation_stage", "baseline-read"),
|
||||
("device_write_attempted", False),
|
||||
("device_write_confirmed", True),
|
||||
("resolved_write_mode", "without_response"),
|
||||
("frame_length", 100),
|
||||
("ble_att_error_code", 14),
|
||||
("ble_att_error_code", "4"),
|
||||
("ble_att_error_code", {}),
|
||||
],
|
||||
)
|
||||
def test_unrelated_transport_failures_are_not_wifi_diagnoses(field: str, value: object) -> None:
|
||||
error = {**station_error(), field: value}
|
||||
assert (
|
||||
reviewed_station_failure_code(error, firmware_version="3.0.2", connection_mode="bridge")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"firmware,mode", [("3.0.1", "bridge"), ("unknown", "bridge"), ("3.0.2", "quick-connect")]
|
||||
)
|
||||
def test_other_firmware_and_quick_connect_do_not_inherit_station_codes(
|
||||
firmware: str, mode: str
|
||||
) -> None:
|
||||
assert (
|
||||
reviewed_station_failure_code(
|
||||
station_error(), firmware_version=firmware, connection_mode=mode
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.linux_host import nm_fields, route_fields
|
||||
from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge
|
||||
from k1link.device_plugins.xgrids_k1.node_sensor import NodeK1Sensor, project_sensor
|
||||
from k1link.viewer.node_rerun import NodeRerunHub
|
||||
|
||||
|
||||
def state():
|
||||
return {
|
||||
"snapshot_runtime_id": "runtime-one",
|
||||
"snapshot_revision": 1,
|
||||
"devices": [
|
||||
{"device_id": "AA:BB:CC:DD:EE:FF", "name": "K1-test", "likely_k1": True},
|
||||
{"device_id": "unrelated", "name": "Other", "likely_k1": False},
|
||||
],
|
||||
"ble_discovery_generation": 1,
|
||||
"desired_connection_mode_revision": 0,
|
||||
"active_connection_mode": "bridge",
|
||||
"connection_lifecycle": {"connection_ready": True, "ready_to_start": True},
|
||||
"selected_device_id": "AA:BB:CC:DD:EE:FF",
|
||||
"source_mode": "idle",
|
||||
"device_session": {
|
||||
"device_id": "synthetic-k1",
|
||||
"device_session_id": "session-test",
|
||||
"opened_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"application_control_session": {
|
||||
"session_generation": 1,
|
||||
"state_revision": 1,
|
||||
"state": "connection-ready",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Facade:
|
||||
def __init__(self):
|
||||
self.current = state()
|
||||
self.actions = []
|
||||
|
||||
async def invoke(self, request):
|
||||
self.actions.append((request.action_id, copy.deepcopy(request.parameters)))
|
||||
if request.action_id == "application-control.workspace.enter":
|
||||
self.current["application_control_session"]["state"] = "workspace-ready"
|
||||
elif request.action_id == "acquisition.prepare":
|
||||
self.current["acquisition"] = {
|
||||
"acquisition_id": "acquisition-test",
|
||||
"state": "prepared",
|
||||
"state_revision": 1,
|
||||
}
|
||||
self.current["application_control_session"]["state"] = "project-ready"
|
||||
elif request.action_id == "acquisition.start":
|
||||
self.current["application_control_session"]["state"] = "initializing"
|
||||
self.current["acquisition"]["state"] = "running"
|
||||
self.current["source_mode"] = "live"
|
||||
return copy.deepcopy(self.current)
|
||||
|
||||
|
||||
def bridge():
|
||||
result = NodeBridge(Path.cwd(), service=object())
|
||||
result.facade = Facade()
|
||||
return result
|
||||
|
||||
|
||||
def test_node_bridge_forces_reviewed_bridge_and_clears_input_secret():
|
||||
async def run():
|
||||
device = bridge()
|
||||
command = {
|
||||
"operation_id": "op_" + "a" * 32,
|
||||
"runtime_id": "runtime-one",
|
||||
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
|
||||
"action": "connect",
|
||||
"parameters": {
|
||||
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||
"discovery_generation": 1,
|
||||
"mode_revision": 0,
|
||||
"ssid": "test-network",
|
||||
"password": secrets.token_urlsafe(24),
|
||||
"allow_host_wifi_switch": True,
|
||||
},
|
||||
}
|
||||
secret = command["parameters"]["password"]
|
||||
output = await device.execute(command)
|
||||
payload = next(v for a, v in device.facade.actions if a == "network.provision")
|
||||
assert payload["connection_mode"] == "bridge"
|
||||
assert payload["allow_host_wifi_switch"] is False
|
||||
assert payload["password"] == secret
|
||||
assert "password" not in command["parameters"]
|
||||
assert secret not in json.dumps(output)
|
||||
assert [v["id"] for v in output["candidates"]] == ["AA:BB:CC:DD:EE:FF"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_stale_discovery_prevents_provisioning():
|
||||
async def run():
|
||||
device = bridge()
|
||||
with pytest.raises(ValueError):
|
||||
await device.execute(
|
||||
{
|
||||
"operation_id": "op_" + "a" * 32,
|
||||
"runtime_id": "runtime-one",
|
||||
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
|
||||
"action": "connect",
|
||||
"parameters": {
|
||||
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||
"discovery_generation": 0,
|
||||
"mode_revision": 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert all(action != "network.provision" for action, _ in device.facade.actions)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_sensor_projection_binds_native_sdk_to_board():
|
||||
value = project_sensor(state(), "node-test")
|
||||
snapshot = DeviceSessionSnapshot.model_validate(value["snapshot"])
|
||||
assert snapshot.context.execution.node_id == "node-test"
|
||||
assert snapshot.context.device.device_id == value["id"]
|
||||
assert value["kind"] == "k1"
|
||||
|
||||
|
||||
def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
|
||||
async def run():
|
||||
device = bridge()
|
||||
sensor = NodeK1Sensor(device, None)
|
||||
item = project_sensor(state(), "node-test")
|
||||
command = {
|
||||
"operation_id": "op_" + "a" * 32,
|
||||
"action_id": "start",
|
||||
"session": {"device_id": item["id"], "session_id": "session-test"},
|
||||
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
|
||||
"parameters": {
|
||||
"operator_confirmed": True,
|
||||
"control_generation": 1,
|
||||
"acquisition_id": None,
|
||||
},
|
||||
}
|
||||
result = await sensor.execute(command, "node-test")
|
||||
actions = [a for a, _ in device.facade.actions if a != "state.read"]
|
||||
assert actions == [
|
||||
"application-control.workspace.enter",
|
||||
"acquisition.prepare",
|
||||
"acquisition.start",
|
||||
]
|
||||
assert result["snapshot"]["acquisition"] == "streaming"
|
||||
command["parameters"]["control_generation"] = 2
|
||||
with pytest.raises(ValueError):
|
||||
await sensor.execute(command, "node-test")
|
||||
assert len([a for a, _ in device.facade.actions if a == "acquisition.start"]) == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_networkmanager_ssids_are_not_split_at_escaped_colons():
|
||||
assert nm_fields(r"field\:network\\name:88:WPA2") == ["field:network\\name", "88", "WPA2"]
|
||||
|
||||
|
||||
def test_linux_kernel_route_is_matched_route_not_resolved_host(monkeypatch):
|
||||
from k1link.device_plugins.xgrids_k1 import linux_host
|
||||
from k1link.device_plugins.xgrids_k1.facade import _classify_host_route
|
||||
|
||||
calls = []
|
||||
|
||||
def run(args):
|
||||
calls.append(args)
|
||||
return '[{"dst":"default","dev":"wlp2s0","gateway":"192.168.1.1"}]'
|
||||
|
||||
monkeypatch.setattr(linux_host, "_run", run)
|
||||
result = route_fields("192.168.2.7")
|
||||
assert calls[0] == ["ip", "-j", "route", "get", "192.168.2.7", "fibmatch"]
|
||||
assert _classify_host_route(result["interface"], result["destination"])[0] == "default"
|
||||
assert _classify_host_route("wlp2s0", "192.168.1.0/24")[0] == "direct"
|
||||
assert _classify_host_route("tailscale0", "192.168.1.7")[0] == "tunnel"
|
||||
|
||||
|
||||
def test_native_node_rrd_opens_no_grpc_listener(monkeypatch):
|
||||
from k1link.viewer import rerun_bridge
|
||||
|
||||
def forbidden(*_args, **_kwargs):
|
||||
raise AssertionError("Node must not expose a gRPC listener")
|
||||
|
||||
monkeypatch.setattr(rerun_bridge, "_select_available_grpc_port", forbidden)
|
||||
hub = NodeRerunHub()
|
||||
publisher = hub.create()
|
||||
publisher.begin_session()
|
||||
subscriber = hub.subscribe()
|
||||
try:
|
||||
data = subscriber.read()
|
||||
assert data[:4] == b"RRF2"
|
||||
finally:
|
||||
subscriber.close()
|
||||
subscriber.thread.join(6)
|
||||
publisher.close()
|
||||
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
import queue
|
||||
|
||||
import pytest
|
||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
||||
|
||||
from k1link.viewer.node_media import NodeMediaPeers, admit_sdp
|
||||
|
||||
SDP_HEADER = "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,kind", [("8.8.8.8", "host"), ("192.168.1.2", "relay"), ("::1", "host")]
|
||||
)
|
||||
def test_media_rejects_non_private_or_relay_candidates(address, kind):
|
||||
with pytest.raises(ValueError):
|
||||
admit_sdp(SDP_HEADER + f"a=candidate:1 1 udp 1 {address} 12345 typ {kind}\r\n")
|
||||
|
||||
|
||||
def test_media_accepts_paired_lan_and_tailnet_candidates():
|
||||
for address in ("192.168.1.2", "100.80.6.113", "peer.local"):
|
||||
admit_sdp(SDP_HEADER + f"a=candidate:1 1 udp 1 {address} 12345 typ host\r\n")
|
||||
|
||||
|
||||
def test_failed_media_offer_retires_peer_without_camera_channel(monkeypatch):
|
||||
async def reject(*_):
|
||||
raise ValueError("Invalid remote description")
|
||||
|
||||
monkeypatch.setattr(RTCPeerConnection, "setRemoteDescription", reject)
|
||||
|
||||
async def run():
|
||||
class Camera:
|
||||
def snapshot(self):
|
||||
return {}
|
||||
|
||||
peers = NodeMediaPeers(None, Camera())
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
await peers.offer({"sdp": SDP_HEADER})
|
||||
assert peers.items == {}
|
||||
finally:
|
||||
await peers.close_all()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
||||
"""One bounded loopback peer, no STUN/TURN, device or external network."""
|
||||
import aioice.ice
|
||||
|
||||
class Subscription:
|
||||
def __init__(self):
|
||||
self.output = queue.Queue()
|
||||
self.output.put(b"RRF2-transport-fixture")
|
||||
|
||||
def read(self):
|
||||
try:
|
||||
return self.output.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
return b""
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
class Hub:
|
||||
def subscribe(self):
|
||||
return Subscription()
|
||||
|
||||
class Camera:
|
||||
def snapshot(self):
|
||||
return {"generation": None}
|
||||
|
||||
async def run():
|
||||
peers = NodeMediaPeers(Hub(), Camera())
|
||||
monkeypatch.setattr(aioice.ice, "get_host_addresses", lambda **_: ["127.0.0.1"])
|
||||
client = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
||||
channel = client.createDataChannel("rrd", ordered=True)
|
||||
client.createDataChannel("camera", ordered=True)
|
||||
received = asyncio.Event()
|
||||
payloads = []
|
||||
|
||||
@channel.on("message")
|
||||
def message(data):
|
||||
payloads.append(data)
|
||||
received.set()
|
||||
|
||||
try:
|
||||
await client.setLocalDescription(await client.createOffer())
|
||||
answer = await peers.offer({"sdp": client.localDescription.sdp})
|
||||
await client.setRemoteDescription(
|
||||
RTCSessionDescription(sdp=answer["sdp"], type="answer")
|
||||
)
|
||||
await asyncio.wait_for(received.wait(), timeout=8)
|
||||
assert payloads == [b"RRF2-transport-fixture"]
|
||||
assert answer["peer_id"] in peers.items
|
||||
assert channel.readyState == "open"
|
||||
finally:
|
||||
await client.close()
|
||||
await peers.close_all()
|
||||
assert not peers.items
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -42,6 +42,29 @@ def _request(*, ui_build_id: str | None = None) -> Request:
|
||||
return Request({"type": "http", "method": "GET", "path": "/", "headers": headers})
|
||||
|
||||
|
||||
def test_scanner_exception_keeps_location_without_private_message_or_locals(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = configure_scanner_diagnostics(tmp_path / "logs")
|
||||
logger = logging.getLogger(f"{SCANNER_LOGGER_NAME}.test")
|
||||
private_detail = "synthetic-private-runtime-detail"
|
||||
try:
|
||||
raise ValueError(private_detail)
|
||||
except ValueError:
|
||||
logger.exception("Camera activation failed")
|
||||
for handler in logging.getLogger(SCANNER_LOGGER_NAME).handlers:
|
||||
handler.flush()
|
||||
serialized = target.read_text(encoding="utf-8").splitlines()[-1]
|
||||
document = json.loads(serialized)
|
||||
assert document["exception_type"] == "ValueError"
|
||||
assert document["exception_site"].startswith("test_viewer_diagnostics_api.py:")
|
||||
assert document["exception_site"].endswith(
|
||||
":test_scanner_exception_keeps_location_without_private_message_or_locals"
|
||||
)
|
||||
assert private_detail not in serialized
|
||||
assert str(Path(__file__).parent) not in serialized
|
||||
|
||||
|
||||
def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -67,6 +90,19 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
"device_write_confirmed": False,
|
||||
"ble_att_error_code": 4,
|
||||
"ble_att_error_name": "INVALID_PDU",
|
||||
"resolved_write_mode": "with_response",
|
||||
"max_write_without_response_size": 253,
|
||||
"mtu_size": 256,
|
||||
"frame_length": 99,
|
||||
"write_characteristic_properties": ["read", "write"],
|
||||
"scan_elapsed_ms": 7300,
|
||||
"scanner_start_ms": 31,
|
||||
"initial_window_ms": 6000,
|
||||
"first_candidate_ms": 7240,
|
||||
"scan_extended": True,
|
||||
"candidate_count": 4,
|
||||
"likely_k1_candidate_count": 1,
|
||||
"discovery_generation": 2,
|
||||
"helper_stage": "compile",
|
||||
"helper_elapsed_ms": 34720,
|
||||
"camera_source_id": "sensor.camera.right",
|
||||
@@ -126,6 +162,19 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
assert document["device_write_confirmed"] is False
|
||||
assert document["ble_att_error_code"] == 4
|
||||
assert document["ble_att_error_name"] == "INVALID_PDU"
|
||||
assert document["resolved_write_mode"] == "with_response"
|
||||
assert document["max_write_without_response_size"] == 253
|
||||
assert document["mtu_size"] == 256
|
||||
assert document["frame_length"] == 99
|
||||
assert document["write_characteristic_properties"] == ["read", "write"]
|
||||
assert document["scan_elapsed_ms"] == 7300
|
||||
assert document["scanner_start_ms"] == 31
|
||||
assert document["initial_window_ms"] == 6000
|
||||
assert document["first_candidate_ms"] == 7240
|
||||
assert document["scan_extended"] is True
|
||||
assert document["candidate_count"] == 4
|
||||
assert document["likely_k1_candidate_count"] == 1
|
||||
assert document["discovery_generation"] == 2
|
||||
assert document["helper_stage"] == "compile"
|
||||
assert document["helper_elapsed_ms"] == 34720
|
||||
assert document["camera_source_id"] == "sensor.camera.right"
|
||||
|
||||
@@ -5256,11 +5256,14 @@ def test_ble_scan_operation_id_is_exactly_once_and_request_bound(
|
||||
if on_admitted is not None:
|
||||
on_admitted()
|
||||
transport_calls += 1
|
||||
return _ble_scan_result("exactly-once-device")
|
||||
return {
|
||||
**_ble_scan_result("exactly-once-device"),
|
||||
"discovery_timing": {"scan_elapsed_ms": 7100, "scan_extended": True},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "scan", successful_scan)
|
||||
operation_id = "op-00000000-0000-4000-8000-000000000001"
|
||||
request = BleScanRequest(duration_seconds=6.0, operation_id=operation_id)
|
||||
request = BleScanRequest(operation_id=operation_id)
|
||||
|
||||
first = asyncio.run(service.scan_ble(request))
|
||||
repeated = asyncio.run(service.scan_ble(request))
|
||||
@@ -5271,8 +5274,10 @@ def test_ble_scan_operation_id_is_exactly_once_and_request_bound(
|
||||
assert first["last_operation"]["result"] == {
|
||||
"candidate_count": 1,
|
||||
"likely_k1_candidate_count": 1,
|
||||
"duration_seconds": 6.0,
|
||||
"duration_seconds": 20.0,
|
||||
"discovery_generation": 1,
|
||||
"scan_elapsed_ms": 7100,
|
||||
"scan_extended": True,
|
||||
}
|
||||
assert repeated["last_operation"]["operation_id"] == operation_id
|
||||
with pytest.raises(ValueError, match="different request"):
|
||||
@@ -21315,6 +21320,61 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
assert "lab-network" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("att_code", "public_code"),
|
||||
[(4, "k1-wifi-network-not-found"), (6, "k1-wifi-credentials-required")],
|
||||
)
|
||||
def test_station_reply_preserves_ambiguity_and_explains_wifi_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
att_code: int,
|
||||
public_code: str,
|
||||
) -> None:
|
||||
from bleak.exc import BleakGATTProtocolError
|
||||
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a"}])
|
||||
calls = 0
|
||||
|
||||
async def rejected_write(
|
||||
*_: object, on_write_dispatch: Any = None, **__: object,
|
||||
) -> dict[str, Any]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
on_write_dispatch(_wifi_status_read(None, device_id="k1-a")["status"], "with_response")
|
||||
exc = BleakGATTProtocolError(att_code)
|
||||
exc.operation_stage = "gatt-write" # type: ignore[attr-defined]
|
||||
exc.device_write_attempted = True # type: ignore[attr-defined]
|
||||
exc.device_write_confirmed = False # type: ignore[attr-defined]
|
||||
exc.att_error_code = att_code # type: ignore[attr-defined]
|
||||
exc.att_error_name = exc.code.name # type: ignore[attr-defined]
|
||||
exc.resolved_write_mode = "with_response" # type: ignore[attr-defined]
|
||||
exc.frame_length = 99 # type: ignore[attr-defined]
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", rejected_write)
|
||||
with pytest.raises(BleakGATTProtocolError):
|
||||
asyncio.run(service.connect(_connect_request(
|
||||
device_id="k1-a", ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
idempotency_key="explicit-station-rejection",
|
||||
)))
|
||||
state = service.state()
|
||||
operation = next(item for item in state["operations"] if item["action"] == "network.provision")
|
||||
error = operation["error"]
|
||||
assert calls == 1
|
||||
assert operation["status"] == "failed"
|
||||
assert error["code"] == public_code
|
||||
assert error["transport_error_code"] == "BleakGATTProtocolError"
|
||||
assert error["ble_att_error_code"] == att_code
|
||||
assert error["safe_to_retry"] is False
|
||||
assert error["device_write_confirmed"] is False
|
||||
assert error["side_effect_status"] == "unknown"
|
||||
assert state["connection_attempt"]["public_error_code"] == public_code
|
||||
assert state["network_mutation_ledger"]["status"] == "unresolved"
|
||||
|
||||
|
||||
def test_ambiguous_ble_write_is_audit_only_for_next_explicit_intent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -181,7 +181,7 @@ def test_scan_lease_rejects_wifi_entrypoints_process_wide(
|
||||
resolution_attempted = True
|
||||
raise AssertionError("busy contender must not touch CoreBluetooth")
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", blocked_discover)
|
||||
monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", blocked_discover)
|
||||
monkeypatch.setattr(
|
||||
wifi_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
|
||||
@@ -299,6 +299,99 @@ def test_camera_selection_is_exclusive_and_hides_device_transport(
|
||||
gateway.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["acquisition", "selected-preview"])
|
||||
def test_camera_records_in_configured_evidence_outside_source_checkout(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, entrypoint: str,
|
||||
) -> None:
|
||||
checkout = tmp_path / "checkout"
|
||||
checkout.mkdir()
|
||||
evidence = tmp_path / "durable" / "sessions"
|
||||
session = evidence / "synthetic-camera-session"
|
||||
session.mkdir(parents=True)
|
||||
monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_fake_ffmpeg(tmp_path)))
|
||||
gateway = XgridsK1CameraGateway(checkout, XGRIDS_K1_PLUGIN_ID, evidence_root=evidence)
|
||||
try:
|
||||
if entrypoint == "acquisition":
|
||||
gateway.activate_recording_producer(
|
||||
"sensor.camera.right", "192.168.1.20", session,
|
||||
pre_prepare_fence=lambda reserve: reserve(),
|
||||
commit_fence=lambda commit: commit(),
|
||||
)
|
||||
else:
|
||||
gateway.select("sensor.camera.right", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
_wait_until(lambda: gateway.snapshot()["recording"]["media_ready"] is True)
|
||||
assert gateway.snapshot()["recording"]["session"] == session.name
|
||||
assert not list(checkout.rglob("*.m4s"))
|
||||
finally:
|
||||
gateway.close()
|
||||
assert any(session.rglob("*.m4s"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["acquisition", "selected-preview"])
|
||||
@pytest.mark.parametrize("escape", ["sibling", "symlink"])
|
||||
def test_camera_rejects_paths_outside_configured_evidence_before_admission(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, entrypoint: str, escape: str,
|
||||
) -> None:
|
||||
checkout = tmp_path / "checkout"
|
||||
checkout.mkdir()
|
||||
evidence = tmp_path / "evidence"
|
||||
evidence.mkdir()
|
||||
outside = checkout / "not-evidence"
|
||||
outside.mkdir()
|
||||
session = outside
|
||||
if escape == "symlink":
|
||||
session = evidence / "escaped-session"
|
||||
session.symlink_to(outside, target_is_directory=True)
|
||||
gateway = XgridsK1CameraGateway(checkout, XGRIDS_K1_PLUGIN_ID, evidence_root=evidence)
|
||||
|
||||
def forbidden(*_args: object, **_kwargs: object) -> bool:
|
||||
raise AssertionError("An escaped path must not reach authority or FFmpeg")
|
||||
|
||||
monkeypatch.setattr(camera_module.subprocess, "Popen", forbidden)
|
||||
try:
|
||||
with pytest.raises(ValueError, match="configured evidence root"):
|
||||
if entrypoint == "acquisition":
|
||||
gateway.activate_recording_producer(
|
||||
"sensor.camera.right", "192.168.1.20", session,
|
||||
pre_prepare_fence=forbidden, commit_fence=forbidden,
|
||||
)
|
||||
else:
|
||||
gateway.start_recording(session)
|
||||
assert gateway.snapshot()["recording"]["active"] is False
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_service_camera_uses_the_same_external_evidence_root_as_acquisition(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
checkout = tmp_path / "checkout"
|
||||
checkout.mkdir()
|
||||
evidence = tmp_path / "configured-evidence"
|
||||
session = evidence / "synthetic-session"
|
||||
session.mkdir(parents=True)
|
||||
monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(evidence))
|
||||
service = XgridsK1CompatibilityService(checkout)
|
||||
authority_entered = False
|
||||
|
||||
def deny_authority(_reserve: Callable[[], bool]) -> bool:
|
||||
nonlocal authority_entered
|
||||
authority_entered = True
|
||||
return False
|
||||
|
||||
try:
|
||||
with pytest.raises(ValueError, match="authority"):
|
||||
service.camera_preview.activate_recording_producer(
|
||||
"sensor.camera.right", "192.168.1.20", session,
|
||||
pre_prepare_fence=deny_authority, commit_fence=lambda _commit: False,
|
||||
)
|
||||
assert authority_entered is True
|
||||
assert service.camera_preview.snapshot()["active_source_id"] is None
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_camera_derived_observer_runs_only_after_durable_archive_commit(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user