346 lines
12 KiB
Python
346 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
|
ApplicationBootstrapError,
|
|
ApplicationControlAuthority,
|
|
LiveDeviceControlBinding,
|
|
ShadowApplicationBootstrapOrchestrator,
|
|
build_canonical_post_start_observation,
|
|
build_shadow_bootstrap,
|
|
decode_and_bind_device_info_response,
|
|
)
|
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
|
|
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
|
|
|
|
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
|
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
CAPTURE_FACTS = json.loads(
|
|
(
|
|
Path(__file__).parent
|
|
/ "fixtures"
|
|
/ "xgrids_k1"
|
|
/ "device_info_fw3_0_2_semantics.json"
|
|
).read_text(encoding="utf-8")
|
|
)
|
|
|
|
|
|
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 _authority() -> ApplicationControlAuthority:
|
|
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
|
|
|
|
|
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
|
values = {
|
|
"vendor_device_id": VENDOR_DEVICE_ID,
|
|
"device_serial": "K1SERIAL01",
|
|
"software_version": CAPTURE_FACTS["software_version"],
|
|
"system_version": CAPTURE_FACTS["system_version"],
|
|
"device_model": CAPTURE_FACTS["device_model"],
|
|
"device_type": CAPTURE_FACTS["platform_type"],
|
|
"is_activated": CAPTURE_FACTS["is_activated"],
|
|
}
|
|
values.update(changes)
|
|
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
|
|
|
|
|
def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
|
header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
|
base_info = b"".join(
|
|
(
|
|
_text(1, "2026-01-01T00:00:00"),
|
|
_text(2, CAPTURE_FACTS["software_version"]),
|
|
_text(3, CAPTURE_FACTS["system_version"]),
|
|
_text(4, "V1.2.3"),
|
|
_text(5, "V1.0"),
|
|
_text(6, CAPTURE_FACTS["device_model"]),
|
|
_text(7, "K1SERIAL01"),
|
|
_text(8, CAPTURE_FACTS["platform_type"]),
|
|
)
|
|
)
|
|
working_status = _uint(1, 1) + _uint(2, 0)
|
|
device_info = _bytes(2, base_info) + _bytes(7, working_status)
|
|
error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success")
|
|
return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error)
|
|
|
|
|
|
def _generic_response(session_id: str) -> bytes:
|
|
header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
|
error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success")
|
|
repeated_vendor_body = _text(3, "opaque-a") + _text(3, "opaque-b")
|
|
return _bytes(1, header) + repeated_vendor_body + _bytes(15, error)
|
|
|
|
|
|
def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None:
|
|
plan = build_shadow_bootstrap(
|
|
_authority(),
|
|
_binding(),
|
|
epoch_seconds=1_752_680_000,
|
|
timezone_name="Europe/Moscow",
|
|
)
|
|
|
|
assert [request.message_type for request in plan.requests] == [
|
|
"DeviceInfoRequest",
|
|
"ModelingStatusRequest",
|
|
"GetRtkAdvanceRequest",
|
|
"DeviceConfigRequest",
|
|
"DeviceInfoRequest",
|
|
"GetRtkAdvanceRequest",
|
|
"GetNtripProfileRequest",
|
|
"GetCloudServerConfigRequest",
|
|
"GetRtkAdvanceRequest",
|
|
"DeviceInfoRequest",
|
|
]
|
|
assert [request.payload_bytes for request in plan.requests] == [
|
|
60,
|
|
64,
|
|
63,
|
|
195,
|
|
135,
|
|
138,
|
|
140,
|
|
145,
|
|
138,
|
|
135,
|
|
]
|
|
assert sum(request.mutates_device for request in plan.requests) == 1
|
|
assert plan.requests[3].mutates_device
|
|
assert plan.executable is False
|
|
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
|
|
assert plan.retry_policy == "never-automatic"
|
|
assert APPLICATION_KEY not in repr(plan)
|
|
assert APPLICATION_KEY not in str(plan.as_dict())
|
|
|
|
|
|
def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None:
|
|
plan = build_shadow_bootstrap(
|
|
_authority(),
|
|
_binding(),
|
|
epoch_seconds=1_752_680_000,
|
|
timezone_name="Europe/Moscow",
|
|
)
|
|
|
|
first_header = next(iter_fields(plan.requests[0].payload)).value
|
|
assert isinstance(first_header, bytes)
|
|
first_fields = {field.number: field.value for field in iter_fields(first_header)}
|
|
assert 4 not in first_fields
|
|
assert first_fields[5] == b":DeviceInfoRequest"
|
|
|
|
config_header = next(iter_fields(plan.requests[3].payload)).value
|
|
assert isinstance(config_header, bytes)
|
|
config_fields = {field.number: field.value for field in iter_fields(config_header)}
|
|
assert config_fields[4] == VENDOR_DEVICE_ID.encode()
|
|
assert (
|
|
config_fields[5]
|
|
== (f"{VENDOR_DEVICE_ID}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime").encode()
|
|
)
|
|
|
|
|
|
def test_device_config_timestamp_uses_captured_sint64_zigzag_wire_semantics() -> None:
|
|
captured_epoch_seconds = 1_784_215_030
|
|
plan = build_shadow_bootstrap(
|
|
_authority(),
|
|
_binding(),
|
|
epoch_seconds=captured_epoch_seconds,
|
|
timezone_name="Europe/Moscow",
|
|
)
|
|
|
|
config_request = plan.requests[3]
|
|
top_fields = {field.number: field.value for field in iter_fields(config_request.payload)}
|
|
time_config = top_fields[4]
|
|
assert isinstance(time_config, bytes)
|
|
time_fields = {field.number: field.value for field in iter_fields(time_config)}
|
|
|
|
assert time_fields == {
|
|
1: 3_568_430_060,
|
|
2: b"Europe/Moscow",
|
|
}
|
|
|
|
|
|
def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None:
|
|
plan = build_canonical_post_start_observation(_authority(), _binding())
|
|
|
|
assert [request.ordinal for request in plan.requests] == [12, 13, 14]
|
|
assert [request.message_type for request in plan.requests] == [
|
|
"ModelingStatusRequest",
|
|
"DeviceInfoRequest",
|
|
"ModelingStatusRequest",
|
|
]
|
|
assert [request.payload_bytes for request in plan.requests] == [139, 135, 139]
|
|
assert [request.response_required for request in plan.requests] == [False, True, True]
|
|
assert all(request.phase == "post-start-observation" for request in plan.requests)
|
|
assert all(not request.mutates_device for request in plan.requests)
|
|
|
|
|
|
def test_device_info_response_produces_live_binding_without_a_saved_device_profile() -> None:
|
|
response = decode_and_bind_device_info_response(_device_info_response(), _authority())
|
|
|
|
assert response.result_code == OPENAPI_SUCCESS
|
|
assert response.binding.ready_for_reviewed_profile
|
|
assert response.binding.matches_reviewed_device
|
|
assert response.binding.matches_reviewed_firmware
|
|
assert response.binding.is_activated
|
|
assert VENDOR_DEVICE_ID not in repr(response)
|
|
assert APPLICATION_KEY not in repr(response)
|
|
|
|
|
|
def test_device_info_response_correlation_and_profile_attestation_fail_closed() -> None:
|
|
with pytest.raises(ApplicationBootstrapError, match="session correlation"):
|
|
decode_and_bind_device_info_response(
|
|
_device_info_response(session_id=":AnotherRequest"),
|
|
_authority(),
|
|
)
|
|
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
|
build_shadow_bootstrap(
|
|
_authority(),
|
|
_binding(system_version="V3.0.3"),
|
|
epoch_seconds=1_752_680_000,
|
|
timezone_name="Europe/Moscow",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("device_model", "device_type"),
|
|
(
|
|
("lixelkity K1", "A4"),
|
|
("LixelKity K1 ", "A4"),
|
|
("LixelKity K11", "A4"),
|
|
("LixelKity K1", "a4"),
|
|
("LixelKity K1", " A4"),
|
|
("LixelKity K1", "K1"),
|
|
),
|
|
)
|
|
def test_reviewed_profile_rejects_non_exact_device_model_and_type(
|
|
device_model: str,
|
|
device_type: str,
|
|
) -> None:
|
|
binding = _binding(device_model=device_model, device_type=device_type)
|
|
|
|
assert binding.matches_reviewed_firmware
|
|
assert not binding.matches_reviewed_device
|
|
assert not binding.ready_for_reviewed_profile
|
|
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
|
build_shadow_bootstrap(
|
|
_authority(),
|
|
binding,
|
|
epoch_seconds=1_752_680_000,
|
|
timezone_name="Europe/Moscow",
|
|
)
|
|
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
|
build_canonical_post_start_observation(_authority(), binding)
|
|
|
|
|
|
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
|
_authority(),
|
|
epoch_seconds=1_752_680_000,
|
|
timezone_name="Europe/Moscow",
|
|
)
|
|
|
|
identity = orchestrator.next_batch()
|
|
assert [request.ordinal for request in identity] == [1]
|
|
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
|
orchestrator.next_batch()
|
|
orchestrator.accept_response(
|
|
"bootstrap:1:DeviceInfoRequest",
|
|
_device_info_response(),
|
|
)
|
|
|
|
initial_reads = orchestrator.next_batch()
|
|
assert [request.ordinal for request in initial_reads] == [2, 3, 4, 5, 6]
|
|
assert [request.response_required for request in initial_reads] == [
|
|
False,
|
|
True,
|
|
True,
|
|
True,
|
|
True,
|
|
]
|
|
device_info = initial_reads[3]
|
|
orchestrator.accept_response(
|
|
"bootstrap:5:DeviceInfoRequest",
|
|
_device_info_response(session_id=device_info.session_id),
|
|
)
|
|
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
|
orchestrator.next_batch()
|
|
for request in (initial_reads[4], initial_reads[1], initial_reads[2]):
|
|
orchestrator.accept_response(
|
|
f"bootstrap:{request.ordinal}:{request.message_type}",
|
|
_generic_response(request.session_id),
|
|
)
|
|
|
|
ntrip = orchestrator.next_batch()
|
|
assert [request.ordinal for request in ntrip] == [7]
|
|
orchestrator.accept_response(
|
|
"bootstrap:7:GetNtripProfileRequest",
|
|
_generic_response(ntrip[0].session_id),
|
|
)
|
|
|
|
final_reads = orchestrator.next_batch()
|
|
assert [request.ordinal for request in final_reads] == [8, 9, 10]
|
|
for request in final_reads:
|
|
payload = (
|
|
_device_info_response(session_id=request.session_id)
|
|
if request.message_type == "DeviceInfoRequest"
|
|
else _generic_response(request.session_id)
|
|
)
|
|
orchestrator.accept_response(
|
|
f"bootstrap:{request.ordinal}:{request.message_type}",
|
|
payload,
|
|
)
|
|
|
|
snapshot = orchestrator.snapshot()
|
|
assert snapshot.bootstrap_complete
|
|
assert snapshot.live_binding_observed
|
|
assert snapshot.pending_response_topics == ()
|
|
assert snapshot.executable is False
|
|
assert snapshot.vendor_writes_enabled is False
|
|
with pytest.raises(ApplicationBootstrapError, match="already complete"):
|
|
orchestrator.next_batch()
|
|
|
|
|
|
def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
|
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
|
_authority(),
|
|
epoch_seconds=1_752_680_000,
|
|
timezone_name="Europe/Moscow",
|
|
)
|
|
orchestrator.next_batch()
|
|
|
|
with pytest.raises(ApplicationBootstrapError, match="not required"):
|
|
orchestrator.accept_response(
|
|
"bootstrap:3:GetRtkAdvanceRequest",
|
|
_generic_response(":GetRtkAdvanceRequest"),
|
|
)
|
|
|
|
assert orchestrator.snapshot().current_batch == 1
|
|
assert orchestrator.snapshot().batch_issued
|
|
|
|
|
|
def test_application_authority_requires_exact_captured_profile_length() -> None:
|
|
with pytest.raises(ApplicationBootstrapError, match="36-byte"):
|
|
ApplicationControlAuthority(openapi_key="too-short")
|