feat(k1): add physical acceptance transport
This commit is contained in:
@@ -25,6 +25,41 @@ def test_doctor_json() -> None:
|
||||
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
||||
|
||||
|
||||
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
|
||||
result = runner.invoke(app, ["authority", "provision"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "provisioning not confirmed" in result.stdout
|
||||
|
||||
|
||||
def test_authority_provision_never_accepts_the_secret_as_a_cli_value(monkeypatch: Any) -> None:
|
||||
calls = 0
|
||||
|
||||
class FakeSnapshot:
|
||||
service = "fixed-service"
|
||||
account = "fixed-account"
|
||||
|
||||
class FakeProvisioner:
|
||||
def provision_interactively(self) -> FakeSnapshot:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return FakeSnapshot()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"k1link.device_plugins.xgrids_k1.cli.MacOSKeychainApplicationAuthorityProvisioner",
|
||||
FakeProvisioner,
|
||||
)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["authority", "provision", "--confirm-reviewed-authority"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert calls == 1
|
||||
assert "Keychain item validated" in result.stdout
|
||||
assert "No K1 command was sent" in " ".join(result.stdout.split())
|
||||
|
||||
|
||||
def test_mqtt_capture_requires_owned_device_confirmation(tmp_path: Path) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||
ApplicationAcceptanceError,
|
||||
PhysicalAcceptanceChecklist,
|
||||
PhysicalAcceptanceDialogueExecutor,
|
||||
PhysicalAcceptancePermit,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||
MODELING_RESPONSE_TOPIC,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
OPENAPI_SUCCESS,
|
||||
CommandHeaderIdentity,
|
||||
ModelingAction,
|
||||
MountType,
|
||||
RecordMode,
|
||||
ScanMode,
|
||||
encode_modeling_start,
|
||||
encode_modeling_stop,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
ShadowModelingCommand,
|
||||
)
|
||||
|
||||
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
||||
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
encoded.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
encoded.append(value)
|
||||
return bytes(encoded)
|
||||
|
||||
|
||||
def _uint(number: int, value: int) -> bytes:
|
||||
return _varint(number << 3) + _varint(value)
|
||||
|
||||
|
||||
def _bytes(number: int, value: bytes) -> bytes:
|
||||
return _varint((number << 3) | 2) + _varint(len(value)) + value
|
||||
|
||||
|
||||
def _text(number: int, value: str) -> bytes:
|
||||
return _bytes(number, value.encode())
|
||||
|
||||
|
||||
def _header(session_id: str) -> bytes:
|
||||
return _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||
|
||||
|
||||
def _device_info_response(session_id: str) -> bytes:
|
||||
base_info = b"".join(
|
||||
(
|
||||
_text(2, "V3.0.2-20260101-release"),
|
||||
_text(3, "V3.0.2"),
|
||||
_text(6, "LixelKity K1"),
|
||||
_text(7, "K1SERIAL01"),
|
||||
_text(8, "K1"),
|
||||
)
|
||||
)
|
||||
working_status = _uint(1, 1)
|
||||
device_info = _bytes(2, base_info) + _bytes(7, working_status)
|
||||
error = _uint(1, OPENAPI_SUCCESS)
|
||||
return _bytes(1, _header(session_id)) + _bytes(2, device_info) + _bytes(15, error)
|
||||
|
||||
|
||||
def _generic_response(session_id: str) -> bytes:
|
||||
return _bytes(1, _header(session_id)) + _bytes(15, _uint(1, OPENAPI_SUCCESS))
|
||||
|
||||
|
||||
def _modeling_response(action: ModelingAction) -> bytes:
|
||||
session = f"{VENDOR_DEVICE_ID}:ModelingRequest"
|
||||
return _bytes(1, _header(session)) + _uint(2, action) + _bytes(15, _uint(1, OPENAPI_SUCCESS))
|
||||
|
||||
|
||||
class SyntheticAcceptanceTransport:
|
||||
def __init__(self) -> None:
|
||||
self.batches: list[tuple[str, ...]] = []
|
||||
|
||||
def exchange_batch_once(
|
||||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_topics: Collection[str],
|
||||
) -> dict[str, bytes]:
|
||||
self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
|
||||
responses: dict[str, bytes] = {}
|
||||
if MODELING_RESPONSE_TOPIC in required_response_topics:
|
||||
action = (
|
||||
ModelingAction.START
|
||||
if envelopes[0].operation_key == "modeling:start"
|
||||
else ModelingAction.STOP
|
||||
)
|
||||
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
|
||||
return responses
|
||||
|
||||
for envelope in envelopes:
|
||||
prefix, ordinal_text, message_type = envelope.operation_key.split(":", 2)
|
||||
assert prefix == "bootstrap"
|
||||
ordinal = int(ordinal_text)
|
||||
if message_type == "DeviceInfoRequest":
|
||||
session = (
|
||||
":DeviceInfoRequest"
|
||||
if ordinal == 1
|
||||
else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest"
|
||||
)
|
||||
response = _device_info_response(session)
|
||||
else:
|
||||
session = (
|
||||
f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}"
|
||||
)
|
||||
if message_type == "DeviceConfigRequest":
|
||||
session += ":Publish_Proto_DeviceConfig_SetTime"
|
||||
response = _generic_response(session)
|
||||
for topic in required_response_topics:
|
||||
if topic.endswith(_response_suffix(message_type)):
|
||||
responses[topic] = response
|
||||
return responses
|
||||
|
||||
|
||||
def _response_suffix(message_type: str) -> str:
|
||||
values = {
|
||||
"DeviceInfoRequest": "device_info",
|
||||
"ModelingStatusRequest": "modeling_status",
|
||||
"GetRtkAdvanceRequest": "get_rtk_advance",
|
||||
"DeviceConfigRequest": "device_config",
|
||||
"GetNtripProfileRequest": "get_ntrip_profile",
|
||||
"GetCloudServerConfigRequest": "get_cloud_server_config",
|
||||
}
|
||||
return values[message_type]
|
||||
|
||||
|
||||
def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||
return PhysicalAcceptanceChecklist(
|
||||
action=action,
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
)
|
||||
|
||||
|
||||
def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit() -> None:
|
||||
transport = SyntheticAcceptanceTransport()
|
||||
permit = PhysicalAcceptancePermit(_checklist(ModelingAction.START))
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport, permit)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
binding = executor.run_bootstrap(orchestrator)
|
||||
command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=APPLICATION_KEY,
|
||||
),
|
||||
project_name="SAFE_PROJECT",
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
response = executor.execute_modeling(command)
|
||||
|
||||
assert response.action is ModelingAction.START
|
||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1]
|
||||
assert permit.snapshot()["consumed"] is True
|
||||
assert executor.snapshot()["bootstrap_complete"] is True
|
||||
assert executor.snapshot()["command_complete"] is True
|
||||
with pytest.raises(ApplicationAcceptanceError, match="already attempted"):
|
||||
executor.execute_modeling(command)
|
||||
|
||||
|
||||
def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() -> None:
|
||||
transport = SyntheticAcceptanceTransport()
|
||||
start = PhysicalAcceptanceDialogueExecutor(
|
||||
transport,
|
||||
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
|
||||
)
|
||||
start_command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY),
|
||||
project_name="SAFE_PROJECT",
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
with pytest.raises(ApplicationAcceptanceError, match="requires.*bootstrap"):
|
||||
start.execute_modeling(start_command)
|
||||
|
||||
stop = PhysicalAcceptanceDialogueExecutor(
|
||||
transport,
|
||||
PhysicalAcceptancePermit(_checklist(ModelingAction.STOP)),
|
||||
)
|
||||
stop_command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_stop(
|
||||
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY)
|
||||
)
|
||||
)
|
||||
assert stop.execute_modeling(stop_command).action is ModelingAction.STOP
|
||||
assert transport.batches[-1] == ("modeling:stop",)
|
||||
|
||||
|
||||
def test_acceptance_permit_rejects_implicit_or_expired_authority() -> None:
|
||||
with pytest.raises(ApplicationAcceptanceError, match="explicitly true"):
|
||||
PhysicalAcceptanceChecklist(
|
||||
action=ModelingAction.START,
|
||||
operator_present=False, # type: ignore[arg-type]
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
)
|
||||
|
||||
now = [100.0]
|
||||
permit = PhysicalAcceptancePermit(
|
||||
_checklist(ModelingAction.STOP),
|
||||
ttl_seconds=15.0,
|
||||
monotonic=lambda: now[0],
|
||||
)
|
||||
now[0] = 115.0
|
||||
with pytest.raises(ApplicationAcceptanceError, match="expired"):
|
||||
permit.consume(ModelingAction.STOP)
|
||||
@@ -10,6 +10,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||
KEYCHAIN_SERVICE,
|
||||
ApplicationAuthorityLoadError,
|
||||
MacOSKeychainApplicationAuthorityLoader,
|
||||
MacOSKeychainApplicationAuthorityProvisioner,
|
||||
)
|
||||
|
||||
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||
@@ -108,3 +109,67 @@ def test_authority_loader_rejects_wrong_profile_length_without_reflection(
|
||||
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
||||
|
||||
assert invalid_secret.decode() not in str(error.value)
|
||||
|
||||
|
||||
@patch("sys.stdout.isatty", return_value=True)
|
||||
@patch("sys.stdin.isatty", return_value=True)
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_provisioning_uses_hidden_security_prompt_and_validates_item(
|
||||
_which: object,
|
||||
_system: object,
|
||||
_stdin_tty: object,
|
||||
_stdout_tty: object,
|
||||
) -> None:
|
||||
provisioning_calls: list[list[str]] = []
|
||||
|
||||
def interactive_runner(
|
||||
args: list[str],
|
||||
*,
|
||||
check: bool,
|
||||
timeout: float,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
provisioning_calls.append(args)
|
||||
assert check is False
|
||||
assert timeout == 300.0
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
|
||||
def lookup_runner(
|
||||
args: list[str],
|
||||
**_kwargs: object,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
return subprocess.CompletedProcess(args, 0, stdout=PRIVATE_AUTHORITY, stderr=b"")
|
||||
|
||||
provisioner = MacOSKeychainApplicationAuthorityProvisioner(
|
||||
runner=interactive_runner,
|
||||
loader=MacOSKeychainApplicationAuthorityLoader(runner=lookup_runner),
|
||||
)
|
||||
snapshot = provisioner.provision_interactively()
|
||||
|
||||
assert provisioning_calls == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"add-generic-password",
|
||||
"-U",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
]
|
||||
]
|
||||
assert PRIVATE_AUTHORITY.decode().strip() not in str(provisioning_calls)
|
||||
assert snapshot.secret_cached is False
|
||||
assert snapshot.secret_exportable is False
|
||||
|
||||
|
||||
@patch("sys.stdout.isatty", return_value=False)
|
||||
@patch("sys.stdin.isatty", return_value=False)
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
def test_authority_provisioning_requires_an_interactive_terminal(
|
||||
_system: object,
|
||||
_stdin_tty: object,
|
||||
_stdout_tty: object,
|
||||
) -> None:
|
||||
with pytest.raises(ApplicationAuthorityLoadError, match="interactive terminal"):
|
||||
MacOSKeychainApplicationAuthorityProvisioner().provision_interactively()
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections import deque
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||
CONTROL_SUBSCRIPTION_GROUPS,
|
||||
ApplicationCommandOutcomeUnknown,
|
||||
ReviewedApplicationMqttTransport,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
|
||||
|
||||
class FakeReasonCode:
|
||||
def __init__(self, *, failure: bool = False) -> None:
|
||||
self.is_failure = failure
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 100.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
class FakeControlClient:
|
||||
def __init__(self, *, emit_exchange: bool = True, clock: FakeClock | None = None) -> None:
|
||||
self.on_connect: Any = None
|
||||
self.on_subscribe: Any = None
|
||||
self.on_publish: Any = None
|
||||
self.on_message: Any = None
|
||||
self.on_disconnect: Any = None
|
||||
self.emit_exchange = emit_exchange
|
||||
self.clock = clock
|
||||
self.events: deque[tuple[str, object]] = deque()
|
||||
self.connect_calls: list[tuple[str, int, int]] = []
|
||||
self.subscribe_calls: list[list[tuple[str, int]]] = []
|
||||
self.publish_calls: list[tuple[str, bytes, int, bool]] = []
|
||||
self.unsubscribe_calls: list[list[str]] = []
|
||||
self.disconnect_calls = 0
|
||||
self.next_mid = 20
|
||||
|
||||
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
|
||||
self.connect_calls.append((host, port, keepalive))
|
||||
self.events.append(("connect", FakeReasonCode()))
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
def subscribe(self, topics: list[tuple[str, int]]) -> tuple[mqtt.MQTTErrorCode, int]:
|
||||
self.subscribe_calls.append(topics)
|
||||
mid = 6 + len(self.subscribe_calls)
|
||||
self.events.append(("subscribe", mid))
|
||||
return mqtt.MQTT_ERR_SUCCESS, mid
|
||||
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> SimpleNamespace:
|
||||
self.publish_calls.append((topic, payload, qos, retain))
|
||||
mid = self.next_mid
|
||||
self.next_mid += 1
|
||||
if self.emit_exchange:
|
||||
self.events.append(("publish", mid))
|
||||
self.events.append(("message", (DEVICE_INFO_RESPONSE_TOPIC, b"response")))
|
||||
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
||||
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
del timeout
|
||||
if self.clock is not None:
|
||||
self.clock.now += 1.0
|
||||
if not self.events:
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
kind, value = self.events.popleft()
|
||||
if kind == "connect":
|
||||
self.on_connect(self, None, SimpleNamespace(), value, None)
|
||||
elif kind == "subscribe":
|
||||
group = CONTROL_SUBSCRIPTION_GROUPS[int(value) - 7]
|
||||
self.on_subscribe(
|
||||
self,
|
||||
None,
|
||||
value,
|
||||
[FakeReasonCode() for _item in group],
|
||||
None,
|
||||
)
|
||||
elif kind == "publish":
|
||||
self.on_publish(self, None, value, FakeReasonCode(), None)
|
||||
elif kind == "message":
|
||||
topic, payload = value
|
||||
self.on_message(self, None, SimpleNamespace(topic=topic, payload=payload))
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
def unsubscribe(self, topics: list[str]) -> tuple[mqtt.MQTTErrorCode, int]:
|
||||
self.unsubscribe_calls.append(topics)
|
||||
return mqtt.MQTT_ERR_SUCCESS, 50
|
||||
|
||||
def disconnect(self) -> mqtt.MQTTErrorCode:
|
||||
self.disconnect_calls += 1
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
|
||||
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope:
|
||||
payload = b"synthetic-reviewed-request"
|
||||
return OneShotPublishEnvelope(
|
||||
operation_key=operation_key,
|
||||
topic="lixel/application/request/device_info",
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
qos=2,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
|
||||
def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None:
|
||||
with pytest.raises(ValueError, match="not a direct-LAN"):
|
||||
ReviewedApplicationMqttTransport("192.168.56.1")
|
||||
|
||||
|
||||
def test_retained_control_subscription_batches_remain_exact_and_separate_from_points() -> None:
|
||||
assert [len(group) for group in CONTROL_SUBSCRIPTION_GROUPS] == [9, 5, 42]
|
||||
assert CONTROL_SUBSCRIPTION_GROUPS[0][-1] == (DEVICE_INFO_RESPONSE_TOPIC, 0)
|
||||
assert ("lixel/application/response/modeling", 2) in CONTROL_SUBSCRIPTION_GROUPS[2]
|
||||
assert all(
|
||||
topic not in {"RealtimePointcloud", "lixel/application/report/lio_pcl"}
|
||||
for group in CONTROL_SUBSCRIPTION_GROUPS
|
||||
for topic, _qos in group
|
||||
)
|
||||
|
||||
|
||||
def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() -> None:
|
||||
fake = FakeControlClient()
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
opened = transport.open().as_dict()
|
||||
responses = transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
transport.close()
|
||||
|
||||
assert fake.connect_calls == [("192.168.1.20", 1883, 60)]
|
||||
assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS]
|
||||
assert fake.publish_calls == [
|
||||
(
|
||||
"lixel/application/request/device_info",
|
||||
b"synthetic-reviewed-request",
|
||||
2,
|
||||
False,
|
||||
)
|
||||
]
|
||||
assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"}
|
||||
assert opened["clean_session"] is False
|
||||
assert opened["automatic_reconnect"] is False
|
||||
assert opened["automatic_retry"] is False
|
||||
snapshot = transport.snapshot().as_dict()
|
||||
assert snapshot["state"] == "closed"
|
||||
assert snapshot["publish_attempts"] == 1
|
||||
assert snapshot["subscribe_attempts"] == 3
|
||||
assert snapshot["qos2_completions"] == 1
|
||||
assert snapshot["correlated_responses"] == 1
|
||||
assert snapshot["operation_keys_consumed"] == 1
|
||||
|
||||
|
||||
def test_consumed_operation_key_can_never_be_published_again() -> None:
|
||||
fake = FakeControlClient()
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
transport.open()
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
|
||||
assert len(fake.publish_calls) == 1
|
||||
|
||||
|
||||
def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
||||
clock = FakeClock()
|
||||
fake = FakeControlClient(emit_exchange=False, clock=clock)
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
exchange_timeout_seconds=2.0,
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
monotonic=clock,
|
||||
)
|
||||
transport.open()
|
||||
|
||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
|
||||
assert transport.snapshot().state == "poisoned"
|
||||
assert len(fake.publish_calls) == 1
|
||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
assert len(fake.publish_calls) == 1
|
||||
@@ -79,6 +79,7 @@ def test_publish_envelope_redacts_payload_and_rejects_retry_semantics() -> None:
|
||||
assert PRIVATE_AUTHORITY not in str(envelope.as_dict())
|
||||
with pytest.raises(ValueError, match="one-shot contract"):
|
||||
OneShotPublishEnvelope(
|
||||
operation_key=envelope.operation_key,
|
||||
topic=envelope.topic,
|
||||
payload=envelope.payload,
|
||||
payload_sha256=envelope.payload_sha256,
|
||||
|
||||
Reference in New Issue
Block a user