Unify K1 discovery ownership and verification across enrollment paths
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""Exercise the installed Bleak manager against BlueZ's single-session contract.
|
||||
|
||||
Only the D-Bus wire is synthetic. No Bluetooth adapter or device is accessed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from bleak import BleakScanner
|
||||
from bleak.backends.bluezdbus import defs
|
||||
from bleak.backends.bluezdbus import manager as manager_module
|
||||
from bleak.backends.bluezdbus import scanner as backend_module
|
||||
from bleak.backends.bluezdbus.manager import BlueZManager
|
||||
from bleak.backends.bluezdbus.scanner import BleakScannerBlueZDBus
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.exc import BleakDBusError
|
||||
from dbus_fast import Message, MessageType
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.ble import scanner
|
||||
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
|
||||
bind_ble_runtime_owner_loop,
|
||||
configure_ble_runtime_process_lease,
|
||||
reset_ble_runtime_arbiter_for_tests,
|
||||
run_ble_operation,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["status-read", "wifi-provision", "ap-enable"])
|
||||
@pytest.mark.parametrize(
|
||||
"mode", ["present", "missing", "busy", "absent", "cancelled", "connect-failed"]
|
||||
)
|
||||
def test_one_discovery_session_retains_exact_path_until_connect(monkeypatch, tmp_path, kind, mode):
|
||||
async def scenario():
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
configure_ble_runtime_process_lease(tmp_path)
|
||||
bind_ble_runtime_owner_loop()
|
||||
manager = BlueZManager()
|
||||
adapter = "/org/bluez/hci7"
|
||||
address = "AA:BB:CC:DD:EE:FF"
|
||||
path = adapter + "/dev_AA_BB_CC_DD_EE_FF"
|
||||
device = BLEDevice(address, "synthetic", {"path": path})
|
||||
manager._properties[adapter] = {defs.ADAPTER_INTERFACE: {"Powered": True}}
|
||||
calls = []
|
||||
discovery_active = False
|
||||
|
||||
def advertise():
|
||||
props = {
|
||||
"Address": address,
|
||||
"Alias": "synthetic",
|
||||
"Name": "synthetic",
|
||||
"Adapter": adapter,
|
||||
"RSSI": -45,
|
||||
}
|
||||
manager._properties[path] = {defs.DEVICE_INTERFACE: props}
|
||||
for callback in tuple(manager._advertisement_callbacks[adapter]):
|
||||
callback(path, props)
|
||||
|
||||
class Bus:
|
||||
async def call(self, message):
|
||||
nonlocal discovery_active
|
||||
calls.append(message.member)
|
||||
if message.member == "StartDiscovery":
|
||||
if discovery_active or mode == "busy":
|
||||
return Message(
|
||||
message_type=MessageType.ERROR,
|
||||
reply_serial=1,
|
||||
error_name="org.bluez.Error.InProgress",
|
||||
signature="s",
|
||||
body=["Operation already in progress"],
|
||||
)
|
||||
discovery_active = True
|
||||
if mode != "absent":
|
||||
asyncio.get_running_loop().call_later(0.01, advertise)
|
||||
elif message.member == "StopDiscovery":
|
||||
discovery_active = False
|
||||
manager._properties.pop(path, None)
|
||||
return Message(message_type=MessageType.METHOD_RETURN, reply_serial=1)
|
||||
|
||||
manager._bus = Bus()
|
||||
if mode == "present":
|
||||
advertise()
|
||||
|
||||
async def get_manager():
|
||||
return manager
|
||||
|
||||
monkeypatch.setattr(manager_module, "get_global_bluez_manager", get_manager)
|
||||
monkeypatch.setattr(backend_module, "get_global_bluez_manager", get_manager)
|
||||
monkeypatch.setattr(scanner, "sys", SimpleNamespace(platform="linux"))
|
||||
|
||||
class LinuxScanner(BleakScanner):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, backend=BleakScannerBlueZDBus, **kwargs)
|
||||
|
||||
monkeypatch.setattr(scanner, "BleakScanner", LinuxScanner)
|
||||
|
||||
async def connect():
|
||||
async with scanner.selected_device_discovery(device, timeout_seconds=1):
|
||||
await asyncio.sleep(0)
|
||||
assert manager.get_device_address(path) == address
|
||||
assert discovery_active
|
||||
calls.append("Connect")
|
||||
if mode == "cancelled":
|
||||
raise asyncio.CancelledError()
|
||||
if mode == "connect-failed":
|
||||
raise ConnectionError("synthetic connection failure")
|
||||
|
||||
try:
|
||||
action = run_ble_operation(
|
||||
kind, operation=lambda _progress: connect(), hard_timeout_seconds=2
|
||||
)
|
||||
expected_error = {
|
||||
"busy": BleakDBusError,
|
||||
"absent": TimeoutError,
|
||||
"cancelled": asyncio.CancelledError,
|
||||
"connect-failed": ConnectionError,
|
||||
}.get(mode)
|
||||
if expected_error:
|
||||
with pytest.raises(expected_error) as raised:
|
||||
await action
|
||||
if mode in {"busy", "absent"}:
|
||||
assert "Connect" not in calls
|
||||
assert raised.value.reason_code == (
|
||||
"ble-discovery-busy"
|
||||
if mode == "busy"
|
||||
else "ble-selected-device-unavailable"
|
||||
)
|
||||
else:
|
||||
await action
|
||||
assert calls.count("StartDiscovery") == 1
|
||||
assert calls.count("StopDiscovery") == (mode != "busy")
|
||||
if "Connect" in calls:
|
||||
assert calls.index("Connect") < calls.index("StopDiscovery")
|
||||
assert not discovery_active
|
||||
assert not manager._advertisement_callbacks[adapter]
|
||||
assert not manager._device_removed_callbacks
|
||||
finally:
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -49,10 +49,8 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
|
||||
)
|
||||
return device if present else None
|
||||
|
||||
async def find(selected_address, *, timeout, bluez):
|
||||
assert selected_address == address
|
||||
assert 0 < timeout <= 8
|
||||
assert bluez == {"adapter": "hci7"}
|
||||
async def advertise(callback):
|
||||
await asyncio.sleep(0)
|
||||
events.append("scan")
|
||||
assert events.count("scan") == 1
|
||||
if native == "absent":
|
||||
@@ -61,11 +59,11 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
|
||||
monkeypatch.setattr(scanner, "ble_runtime_owner_epoch_for_current_loop", lambda: -1)
|
||||
if native == "invalidated":
|
||||
scanner.demote_connected_device_handle_after_gatt_failure(capture)
|
||||
return BLEDevice(
|
||||
callback(BLEDevice(
|
||||
"AA:BB:CC:DD:EE:00" if native == "wrong-address" else address,
|
||||
"synthetic",
|
||||
{"path": path.replace("hci7", "hci8") if native == "wrong-adapter" else path},
|
||||
)
|
||||
), None)
|
||||
|
||||
service = SimpleNamespace(uuid=wifi.SERVICE_UUID)
|
||||
write = SimpleNamespace(
|
||||
@@ -129,17 +127,21 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
|
||||
))
|
||||
monkeypatch.setattr(scanner, "_retrieve_bluez_device", retrieve)
|
||||
class Scanner:
|
||||
find_device_by_address = staticmethod(find)
|
||||
|
||||
def __init__(self, *, bluez):
|
||||
def __init__(self, *, detection_callback, bluez):
|
||||
assert bluez == {"adapter": "hci7"}
|
||||
self.callback = detection_callback
|
||||
self.task = None
|
||||
|
||||
async def __aenter__(self):
|
||||
events.append("hold-discovery")
|
||||
if native not in {"present", "cache-cleanup-race"}:
|
||||
self.task = asyncio.create_task(advertise(self.callback))
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
events.append("release-discovery")
|
||||
if self.task:
|
||||
await self.task
|
||||
|
||||
monkeypatch.setattr(scanner, "BleakScanner", Scanner)
|
||||
monkeypatch.setattr(wifi, "BleakClient", Client)
|
||||
@@ -172,14 +174,17 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n
|
||||
elif fails_before_connect or native == "connect-failed" or (
|
||||
native == "write-failed" and operation == "provision"
|
||||
):
|
||||
with pytest.raises(BleakError) as raised:
|
||||
with pytest.raises((BleakError, TimeoutError)) as raised:
|
||||
await action
|
||||
if fails_before_connect:
|
||||
assert isinstance(raised.value, BleakDeviceNotFoundError)
|
||||
assert isinstance(raised.value, (BleakDeviceNotFoundError, TimeoutError))
|
||||
assert "connect" not in events
|
||||
if operation == "provision":
|
||||
assert raised.value.device_write_attempted is (native == "write-failed")
|
||||
assert events.count("write") == (native == "write-failed")
|
||||
if fails_before_connect and native not in {"owner-changed", "invalidated"}:
|
||||
# A failed discovery has not tested/revoked the original capture.
|
||||
assert scanner.captured_device_handle(capture) is device
|
||||
else:
|
||||
result = await action
|
||||
assert result.get("outcome", "lan_address_observed") == "lan_address_observed"
|
||||
|
||||
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
|
||||
position += 60 + length + length % 2
|
||||
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
||||
control = archive.extractfile("control").read().decode()
|
||||
assert "Depends: mission-core-node (>= 0.8.11)" in control
|
||||
assert "Depends: mission-core-node (>= 0.8.12)" in control
|
||||
assert "Replaces: mission-core-node (<< 0.8.0)" in control
|
||||
|
||||
|
||||
|
||||
@@ -198,6 +198,33 @@ def test_failure_locations_respects_suppression_and_bounds_cyclic_chains():
|
||||
assert failure_locations(first) == "RuntimeError[]"
|
||||
|
||||
|
||||
def test_native_failure_facts_keep_stage_and_write_boundary_without_messages():
|
||||
from bleak.exc import BleakDBusError
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.node_bridge import failure_transport_facts
|
||||
|
||||
secret = secrets.token_hex(20)
|
||||
native = BleakDBusError("org.bluez.Error.InProgress", [secret])
|
||||
native.operation_stage = "resolution"
|
||||
native.device_write_attempted = False
|
||||
native.device_write_confirmed = False
|
||||
wrapper = RuntimeError(secret)
|
||||
wrapper.__cause__ = native
|
||||
facts = failure_transport_facts(wrapper)
|
||||
assert facts == (
|
||||
"stage=resolution bluez=org.bluez.Error.InProgress "
|
||||
"device_write_attempted=False device_write_confirmed=False"
|
||||
)
|
||||
assert secret not in facts
|
||||
native = BleakDBusError(secret, [secret])
|
||||
native.operation_stage = secret
|
||||
wrapper.__cause__ = native
|
||||
assert secret not in failure_transport_facts(wrapper)
|
||||
wrapper.__suppress_context__ = True
|
||||
wrapper.__cause__ = None
|
||||
assert failure_transport_facts(wrapper) == "unavailable"
|
||||
|
||||
|
||||
def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
|
||||
"""Exercise the actual admission boundary, replacing only the BLE service."""
|
||||
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict
|
||||
@@ -640,6 +667,9 @@ def test_recheck_uses_only_exact_admitted_durable_bridge_target():
|
||||
assert result["source"] == "durable-configured-state"
|
||||
assert result["device_id"] == current["selected_device_id"]
|
||||
assert result["expected_mode_revision"] == current["desired_connection_mode_revision"]
|
||||
assert "source" not in verification_parameters(
|
||||
current, "synthetic-operation", requested_device_id="other-device",
|
||||
)
|
||||
for patch in [{"allowed": False}, {"requires_live_gatt_validation": True},
|
||||
{"required_connection_mode": "quick-connect"},
|
||||
{"required_transport_ref": "other"}]:
|
||||
@@ -648,3 +678,30 @@ def test_recheck_uses_only_exact_admitted_durable_bridge_target():
|
||||
decision.update(allowed=True, requires_live_gatt_validation=False,
|
||||
required_transport_ref=current["selected_device_id"],
|
||||
required_connection_mode="bridge")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("requires_gatt", [False, True])
|
||||
def test_enrollment_verify_and_detail_share_durable_target_admission(requires_gatt):
|
||||
async def run():
|
||||
device = bridge()
|
||||
current = device.facade.current
|
||||
current["connection_policy"] = {"actions": {"observe-configured-device-network": {
|
||||
"allowed": True, "requires_live_gatt_validation": requires_gatt,
|
||||
"required_connection_mode": "bridge",
|
||||
"required_transport_ref": current["selected_device_id"],
|
||||
}}}
|
||||
command = {
|
||||
"operation_id": "op_" + "d" * 32, "action": "verify", "runtime_id": "runtime-one",
|
||||
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
|
||||
"parameters": {"device_id": current["selected_device_id"],
|
||||
"discovery_generation": 1, "mode_revision": 0},
|
||||
}
|
||||
await device.execute(command)
|
||||
actions = [(a, p) for a, p in device.facade.actions if a != "state.read"]
|
||||
assert len(actions) == 1 and actions[0][0] == "connection.verify"
|
||||
payload = actions[0][1]
|
||||
assert (payload.get("source") == "durable-configured-state") is not requires_gatt
|
||||
assert payload["device_id"] == current["selected_device_id"]
|
||||
assert payload["expected_discovery_generation"] == 1
|
||||
assert "password" not in payload
|
||||
asyncio.run(run())
|
||||
|
||||
Reference in New Issue
Block a user