feat(k1): add offline control execution gate
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||
KEYCHAIN_ACCOUNT,
|
||||
KEYCHAIN_SERVICE,
|
||||
ApplicationAuthorityLoadError,
|
||||
MacOSKeychainApplicationAuthorityLoader,
|
||||
)
|
||||
|
||||
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_loads_only_from_fixed_macos_keychain_item(
|
||||
_which: object,
|
||||
_system: object,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def runner(
|
||||
args: list[str],
|
||||
*,
|
||||
capture_output: bool,
|
||||
check: bool,
|
||||
timeout: float,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
calls.append(args)
|
||||
assert capture_output
|
||||
assert check is False
|
||||
assert timeout == 5.0
|
||||
return subprocess.CompletedProcess(args, 0, stdout=PRIVATE_AUTHORITY, stderr=b"")
|
||||
|
||||
loader = MacOSKeychainApplicationAuthorityLoader(runner=runner)
|
||||
authority = loader.load()
|
||||
|
||||
assert calls == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
]
|
||||
]
|
||||
assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority)
|
||||
assert loader.snapshot().as_dict() == {
|
||||
"provider": "macos-keychain",
|
||||
"service": KEYCHAIN_SERVICE,
|
||||
"account": KEYCHAIN_ACCOUNT,
|
||||
"secret_cached": False,
|
||||
"secret_exportable": False,
|
||||
"file_fallback_enabled": False,
|
||||
"environment_fallback_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Linux")
|
||||
def test_authority_loader_has_no_non_keychain_fallback(_system: object) -> None:
|
||||
loader = MacOSKeychainApplicationAuthorityLoader()
|
||||
|
||||
with pytest.raises(ApplicationAuthorityLoadError, match="only through macOS Keychain"):
|
||||
loader.load()
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_loader_redacts_keychain_failures(
|
||||
_which: object,
|
||||
_system: object,
|
||||
) -> None:
|
||||
private_error = b"private-keychain-diagnostic"
|
||||
|
||||
def runner(
|
||||
args: list[str],
|
||||
**_kwargs: object,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
return subprocess.CompletedProcess(args, 44, stdout=b"", stderr=private_error)
|
||||
|
||||
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
||||
|
||||
assert private_error.decode() not in str(error.value)
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_loader_rejects_wrong_profile_length_without_reflection(
|
||||
_which: object,
|
||||
_system: object,
|
||||
) -> None:
|
||||
invalid_secret = b"too-short-private-value"
|
||||
|
||||
def runner(
|
||||
args: list[str],
|
||||
**_kwargs: object,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
return subprocess.CompletedProcess(args, 0, stdout=invalid_secret, stderr=b"")
|
||||
|
||||
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
|
||||
|
||||
assert invalid_secret.decode() not in str(error.value)
|
||||
@@ -6,6 +6,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationBootstrapError,
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_shadow_bootstrap,
|
||||
decode_and_bind_device_info_response,
|
||||
)
|
||||
@@ -75,6 +76,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
||||
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(),
|
||||
@@ -165,3 +173,89 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
|
||||
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(identity[0].response_topic, _device_info_response())
|
||||
|
||||
initial_reads = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in initial_reads] == [2, 3]
|
||||
assert [request.response_required for request in initial_reads] == [False, True]
|
||||
orchestrator.accept_response(
|
||||
initial_reads[1].response_topic,
|
||||
_generic_response(initial_reads[1].session_id),
|
||||
)
|
||||
|
||||
preparation = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in preparation] == [4, 5, 6]
|
||||
device_info = preparation[1]
|
||||
orchestrator.accept_response(
|
||||
device_info.response_topic,
|
||||
_device_info_response(session_id=device_info.session_id),
|
||||
)
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
for request in (preparation[2], preparation[0]):
|
||||
orchestrator.accept_response(
|
||||
request.response_topic,
|
||||
_generic_response(request.session_id),
|
||||
)
|
||||
|
||||
ntrip = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in ntrip] == [7]
|
||||
orchestrator.accept_response(
|
||||
ntrip[0].response_topic,
|
||||
_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(request.response_topic, 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(
|
||||
"lixel/application/response/get_rtk_advance",
|
||||
_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")
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
build_shadow_bootstrap,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
VendorWritesDisabledError,
|
||||
WriteDisabledOneShotPublisher,
|
||||
)
|
||||
|
||||
PRIVATE_AUTHORITY = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
|
||||
class SpySink:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> None:
|
||||
del topic, payload, qos, retain
|
||||
self.calls += 1
|
||||
|
||||
|
||||
def _envelope() -> OneShotPublishEnvelope:
|
||||
authority = ApplicationControlAuthority(openapi_key=PRIVATE_AUTHORITY)
|
||||
binding = LiveDeviceControlBinding(
|
||||
vendor_device_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
device_serial="K1SERIAL01",
|
||||
software_version="V3.0.2-build.1",
|
||||
system_version="V3.0.2",
|
||||
device_model="LixelKity K1",
|
||||
device_type="K1",
|
||||
is_activated=True,
|
||||
)
|
||||
request = build_shadow_bootstrap(
|
||||
authority,
|
||||
binding,
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
).requests[0]
|
||||
return OneShotPublishEnvelope.from_bootstrap_request(request)
|
||||
|
||||
|
||||
def test_write_disabled_publisher_never_touches_injected_transport() -> None:
|
||||
sink = SpySink()
|
||||
publisher = WriteDisabledOneShotPublisher(sink)
|
||||
envelope = _envelope()
|
||||
|
||||
for _ in range(2):
|
||||
with pytest.raises(VendorWritesDisabledError, match="cannot publish"):
|
||||
publisher.publish_once(envelope)
|
||||
|
||||
assert sink.calls == 0
|
||||
assert publisher.snapshot().as_dict() == {
|
||||
"mode": "write-disabled",
|
||||
"denied_attempts": 2,
|
||||
"transport_calls": 0,
|
||||
"vendor_writes_enabled": False,
|
||||
"publisher_armed": False,
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_publish_envelope_redacts_payload_and_rejects_retry_semantics() -> None:
|
||||
envelope = _envelope()
|
||||
|
||||
assert PRIVATE_AUTHORITY not in repr(envelope)
|
||||
assert PRIVATE_AUTHORITY not in str(envelope.as_dict())
|
||||
with pytest.raises(ValueError, match="one-shot contract"):
|
||||
OneShotPublishEnvelope(
|
||||
topic=envelope.topic,
|
||||
payload=envelope.payload,
|
||||
payload_sha256=envelope.payload_sha256,
|
||||
payload_bytes=envelope.payload_bytes,
|
||||
qos=1,
|
||||
retain=False,
|
||||
)
|
||||
@@ -65,7 +65,7 @@ def _status_message(
|
||||
|
||||
|
||||
def _authority() -> ApplicationControlAuthority:
|
||||
return ApplicationControlAuthority(openapi_key="application-private-key")
|
||||
return ApplicationControlAuthority(openapi_key="11111111-2222-3333-4444-555555555555")
|
||||
|
||||
|
||||
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||
@@ -120,8 +120,8 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
|
||||
assert plan.executable is False
|
||||
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
|
||||
assert plan.retry_policy == "never-automatic"
|
||||
assert "application-private-key" not in repr(plan)
|
||||
assert "application-private-key" not in str(plan.as_dict())
|
||||
assert "11111111-2222-3333-4444-555555555555" not in repr(plan)
|
||||
assert "11111111-2222-3333-4444-555555555555" not in str(plan.as_dict())
|
||||
|
||||
|
||||
def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None:
|
||||
|
||||
Reference in New Issue
Block a user