562 lines
18 KiB
Python
562 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from http.client import BadStatusLine
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from k1link.device_plugins.xgrids_k1 import cli
|
|
from k1link.device_plugins.xgrids_k1.cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
class _FakeServeLease:
|
|
def __init__(self) -> None:
|
|
self.active = False
|
|
|
|
def __enter__(self) -> _FakeServeLease:
|
|
assert self.active is False
|
|
self.active = True
|
|
return self
|
|
|
|
def __exit__(self, *_: object) -> None:
|
|
assert self.active is True
|
|
self.active = False
|
|
|
|
|
|
def _install_fake_serve_lease(
|
|
monkeypatch: Any,
|
|
*,
|
|
error: BaseException | None = None,
|
|
) -> _FakeServeLease:
|
|
lease = _FakeServeLease()
|
|
|
|
def acquire(_: Path) -> _FakeServeLease:
|
|
if error is not None:
|
|
raise error
|
|
return lease
|
|
|
|
monkeypatch.setattr(cli, "_acquire_mission_core_serve_lease", acquire)
|
|
return lease
|
|
|
|
|
|
def test_help() -> None:
|
|
result = runner.invoke(app, ["--help"])
|
|
assert result.exit_code == 0
|
|
assert "evidence-led" in result.stdout
|
|
|
|
|
|
def test_doctor_json() -> None:
|
|
result = runner.invoke(app, ["doctor", "--json"])
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.stdout)
|
|
assert payload["k1link_version"] == "0.1.0"
|
|
assert isinstance(payload["tools"], list)
|
|
assert isinstance(payload["network"], dict)
|
|
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
|
|
|
|
|
def test_serve_resolves_frontend_from_repository_root(monkeypatch: Any) -> None:
|
|
captured: dict[str, object] = {}
|
|
lease = _install_fake_serve_lease(monkeypatch)
|
|
|
|
def fake_run(application: str, **kwargs: object) -> None:
|
|
assert lease.active is True
|
|
captured.update({"application": application, **kwargs})
|
|
|
|
monkeypatch.setattr(cli.uvicorn, "run", fake_run)
|
|
monkeypatch.setattr(cli, "_local_server_status", lambda _: "free")
|
|
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 0
|
|
assert captured == {
|
|
"application": "k1link.web.app:app",
|
|
"host": "127.0.0.1",
|
|
"port": 8000,
|
|
"log_level": "info",
|
|
"access_log": True,
|
|
"timeout_graceful_shutdown": 10,
|
|
}
|
|
assert lease.active is False
|
|
|
|
|
|
def test_serve_releases_singleton_lease_when_uvicorn_fails(monkeypatch: Any) -> None:
|
|
lease = _install_fake_serve_lease(monkeypatch)
|
|
monkeypatch.setattr(cli, "_local_server_status", lambda _: "free")
|
|
|
|
def failed_run(*_: object, **__: object) -> None:
|
|
assert lease.active is True
|
|
raise RuntimeError("startup failed")
|
|
|
|
monkeypatch.setattr(cli.uvicorn, "run", failed_run)
|
|
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 1
|
|
assert isinstance(result.exception, RuntimeError)
|
|
assert lease.active is False
|
|
|
|
|
|
def test_serve_reuses_an_existing_mission_core_backend(monkeypatch: Any) -> None:
|
|
lease = _install_fake_serve_lease(monkeypatch)
|
|
monkeypatch.setattr(cli, "_local_server_status", lambda _: "mission-core")
|
|
|
|
def unexpected_run(*_: object, **__: object) -> None:
|
|
raise AssertionError("a second backend must not be started")
|
|
|
|
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
|
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "уже запущен" in result.stdout
|
|
assert "второй процесс не создан" in result.stdout
|
|
assert lease.active is False
|
|
|
|
|
|
def test_serve_rejects_an_unrelated_loopback_listener(monkeypatch: Any) -> None:
|
|
lease = _install_fake_serve_lease(monkeypatch)
|
|
monkeypatch.setattr(cli, "_local_server_status", lambda _: "occupied")
|
|
|
|
def unexpected_run(*_: object, **__: object) -> None:
|
|
raise AssertionError("an occupied port must fail before Uvicorn startup")
|
|
|
|
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
|
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 2
|
|
assert "уже занят" in result.stdout
|
|
assert "Mission Core не стал создавать второй backend" in result.stdout
|
|
assert "--port" not in result.stdout
|
|
assert lease.active is False
|
|
|
|
|
|
def test_serve_rejects_noncanonical_port_before_lock_or_health(monkeypatch: Any) -> None:
|
|
def unexpected_call(*_: object, **__: object) -> None:
|
|
raise AssertionError("noncanonical port must fail before lock or health inspection")
|
|
|
|
monkeypatch.setattr(cli, "_acquire_mission_core_serve_lease", unexpected_call)
|
|
monkeypatch.setattr(cli, "_local_server_status", unexpected_call)
|
|
monkeypatch.setattr(cli.uvicorn, "run", unexpected_call)
|
|
|
|
result = runner.invoke(app, ["serve", "--port", "8001"])
|
|
|
|
assert result.exit_code == 2
|
|
assert "только на каноническом порту 8000" in result.stdout
|
|
assert "Другой локальный backend не создан" in result.stdout
|
|
|
|
|
|
def test_serve_busy_lock_reuses_confirmed_mission_core(monkeypatch: Any) -> None:
|
|
_install_fake_serve_lease(
|
|
monkeypatch,
|
|
error=cli._MissionCoreServeLeaseUnavailable("busy"),
|
|
)
|
|
monkeypatch.setattr(cli, "_local_server_status", lambda _: "mission-core")
|
|
|
|
def unexpected_run(*_: object, **__: object) -> None:
|
|
raise AssertionError("busy singleton lease must not start Uvicorn")
|
|
|
|
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
|
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "уже запущен" in result.stdout
|
|
assert "второй процесс не создан" in result.stdout
|
|
|
|
|
|
def test_serve_busy_lock_reports_in_progress_when_health_is_not_listening(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
_install_fake_serve_lease(
|
|
monkeypatch,
|
|
error=cli._MissionCoreServeLeaseUnavailable("busy"),
|
|
)
|
|
monkeypatch.setattr(cli, "_local_server_status", lambda _: "free")
|
|
|
|
def unexpected_run(*_: object, **__: object) -> None:
|
|
raise AssertionError("busy singleton lease must not start Uvicorn")
|
|
|
|
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
|
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 2
|
|
assert "уже запускается или завершает работу" in result.stdout
|
|
assert "Второй backend не создан" in result.stdout
|
|
|
|
|
|
def test_serve_busy_lock_rejects_unconfirmed_health_listener(monkeypatch: Any) -> None:
|
|
_install_fake_serve_lease(
|
|
monkeypatch,
|
|
error=cli._MissionCoreServeLeaseUnavailable("busy"),
|
|
)
|
|
monkeypatch.setattr(cli, "_local_server_status", lambda _: "occupied")
|
|
|
|
def unexpected_run(*_: object, **__: object) -> None:
|
|
raise AssertionError("busy singleton lease must not start Uvicorn")
|
|
|
|
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
|
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 2
|
|
assert "канонический health endpoint" in result.stdout
|
|
assert "подтверждён" in result.stdout
|
|
assert "Второй backend не создан" in result.stdout
|
|
|
|
|
|
def test_serve_lock_integrity_error_fails_before_health_or_uvicorn(monkeypatch: Any) -> None:
|
|
_install_fake_serve_lease(
|
|
monkeypatch,
|
|
error=cli._MissionCoreServeLeaseError("unsafe lock"),
|
|
)
|
|
|
|
def unexpected_call(*_: object, **__: object) -> None:
|
|
raise AssertionError("an untrusted lock must block health and Uvicorn")
|
|
|
|
monkeypatch.setattr(cli, "_local_server_status", unexpected_call)
|
|
monkeypatch.setattr(cli.uvicorn, "run", unexpected_call)
|
|
|
|
result = runner.invoke(app, ["serve"])
|
|
|
|
assert result.exit_code == 2
|
|
assert "безопасно получить блокировку" in result.stdout
|
|
assert "Второй backend не создан" in result.stdout
|
|
|
|
|
|
def test_serve_lock_is_stable_private_and_cross_process(tmp_path: Path) -> None:
|
|
expected_path = tmp_path / ".runtime" / "mission-core" / ".serve.lock"
|
|
|
|
with cli._MissionCoreServeLease.acquire(tmp_path) as first:
|
|
assert first.path == expected_path
|
|
first_identity = (expected_path.stat().st_dev, expected_path.stat().st_ino)
|
|
assert expected_path.stat().st_mode & 0o777 == 0o600
|
|
assert expected_path.parent.stat().st_mode & 0o777 == 0o700
|
|
child = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
(
|
|
"import sys; from pathlib import Path; "
|
|
"from k1link.device_plugins.xgrids_k1.cli import "
|
|
"_MissionCoreServeLease, _MissionCoreServeLeaseUnavailable; "
|
|
"root=Path(sys.argv[1]); "
|
|
"\ntry: lease=_MissionCoreServeLease.acquire(root)"
|
|
"\nexcept _MissionCoreServeLeaseUnavailable: raise SystemExit(0)"
|
|
"\nelse: lease.release(); raise SystemExit(1)"
|
|
),
|
|
str(tmp_path),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
assert child.returncode == 0, child.stderr
|
|
|
|
assert expected_path.is_file()
|
|
with cli._MissionCoreServeLease.acquire(tmp_path) as second:
|
|
assert (second.path.stat().st_dev, second.path.stat().st_ino) == first_identity
|
|
|
|
|
|
def test_serve_lock_rejects_nonprivate_lock_directory(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / ".runtime" / "mission-core"
|
|
lock_dir.mkdir(mode=0o755, parents=True)
|
|
lock_dir.chmod(0o755)
|
|
|
|
with pytest.raises(cli._MissionCoreServeLeaseError, match="not private"):
|
|
cli._MissionCoreServeLease.acquire(tmp_path)
|
|
|
|
|
|
def test_serve_lock_rejects_symlinked_lock_directory(tmp_path: Path) -> None:
|
|
runtime_dir = tmp_path / ".runtime"
|
|
runtime_dir.mkdir()
|
|
redirected = tmp_path / "redirected"
|
|
redirected.mkdir(mode=0o700)
|
|
(runtime_dir / "mission-core").symlink_to(redirected, target_is_directory=True)
|
|
|
|
with pytest.raises(cli._MissionCoreServeLeaseError, match="not private"):
|
|
cli._MissionCoreServeLease.acquire(tmp_path)
|
|
|
|
|
|
class _FakeHealthResponse:
|
|
def __init__(self, *, status: int = 200, body: bytes = b"") -> None:
|
|
self.status = status
|
|
self.body = body
|
|
|
|
def read(self, limit: int) -> bytes:
|
|
assert limit == 16_385
|
|
return self.body[:limit]
|
|
|
|
|
|
class _FakeHealthConnection:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
response: _FakeHealthResponse | None = None,
|
|
error: BaseException | None = None,
|
|
) -> None:
|
|
self.response = response
|
|
self.error = error
|
|
self.request_call: tuple[str, str, dict[str, str]] | None = None
|
|
self.closed = False
|
|
|
|
def request(self, method: str, path: str, *, headers: dict[str, str]) -> None:
|
|
self.request_call = (method, path, headers)
|
|
if self.error is not None:
|
|
raise self.error
|
|
|
|
def getresponse(self) -> _FakeHealthResponse:
|
|
assert self.response is not None
|
|
return self.response
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
def _install_fake_health_connection(
|
|
monkeypatch: Any,
|
|
connection: _FakeHealthConnection,
|
|
) -> None:
|
|
def fake_connection(host: str, port: int, *, timeout: float) -> _FakeHealthConnection:
|
|
assert (host, port, timeout) == ("127.0.0.1", 8000, 0.75)
|
|
return connection
|
|
|
|
monkeypatch.setattr(cli.http.client, "HTTPConnection", fake_connection)
|
|
|
|
|
|
def test_local_server_status_recognizes_exact_mission_core_health(monkeypatch: Any) -> None:
|
|
connection = _FakeHealthConnection(
|
|
response=_FakeHealthResponse(
|
|
body=json.dumps({"service": "mission-core-control-plane"}).encode("utf-8")
|
|
)
|
|
)
|
|
_install_fake_health_connection(monkeypatch, connection)
|
|
|
|
assert cli._local_server_status(8000) == "mission-core"
|
|
assert connection.request_call == (
|
|
"GET",
|
|
"/api/health",
|
|
{"Accept": "application/json", "Connection": "close"},
|
|
)
|
|
assert connection.closed is True
|
|
|
|
|
|
def test_local_server_status_rejects_a_different_health_identity(monkeypatch: Any) -> None:
|
|
connection = _FakeHealthConnection(
|
|
response=_FakeHealthResponse(body=json.dumps({"service": "other"}).encode("utf-8"))
|
|
)
|
|
_install_fake_health_connection(monkeypatch, connection)
|
|
|
|
assert cli._local_server_status(8000) == "occupied"
|
|
assert connection.closed is True
|
|
|
|
|
|
def test_local_server_status_only_treats_explicit_refusal_as_free(monkeypatch: Any) -> None:
|
|
connection = _FakeHealthConnection(error=ConnectionRefusedError())
|
|
_install_fake_health_connection(monkeypatch, connection)
|
|
|
|
assert cli._local_server_status(8000) == "free"
|
|
assert connection.closed is True
|
|
|
|
|
|
def test_local_server_status_fails_closed_on_listener_timeout(monkeypatch: Any) -> None:
|
|
connection = _FakeHealthConnection(error=TimeoutError())
|
|
_install_fake_health_connection(monkeypatch, connection)
|
|
|
|
assert cli._local_server_status(8000) == "occupied"
|
|
assert connection.closed is True
|
|
|
|
|
|
def test_local_server_status_fails_closed_on_non_http_listener(monkeypatch: Any) -> None:
|
|
connection = _FakeHealthConnection(error=BadStatusLine("not HTTP"))
|
|
_install_fake_health_connection(monkeypatch, connection)
|
|
|
|
assert cli._local_server_status(8000) == "occupied"
|
|
assert connection.closed is True
|
|
|
|
|
|
def test_local_server_status_rejects_oversized_health_payload(monkeypatch: Any) -> None:
|
|
connection = _FakeHealthConnection(
|
|
response=_FakeHealthResponse(body=b" " * 16_385)
|
|
)
|
|
_install_fake_health_connection(monkeypatch, connection)
|
|
|
|
assert cli._local_server_status(8000) == "occupied"
|
|
assert connection.closed is True
|
|
|
|
|
|
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,
|
|
[
|
|
"net",
|
|
"mqtt-capture",
|
|
"--host",
|
|
"192.168.1.20",
|
|
"--out",
|
|
str(tmp_path / "capture"),
|
|
],
|
|
)
|
|
assert result.exit_code == 2
|
|
assert "ownership not confirmed" in result.stdout
|
|
assert not (tmp_path / "capture").exists()
|
|
|
|
|
|
def test_mqtt_capture_cli_uses_bounded_read_only_capture(
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_capture(host: str, out: Path, **kwargs: object) -> dict[str, object]:
|
|
on_ready = kwargs.pop("on_ready")
|
|
assert callable(on_ready)
|
|
on_ready()
|
|
captured.update({"host": host, "out": out, **kwargs})
|
|
return {
|
|
"stop_reason": "duration_elapsed",
|
|
"message_count": 2,
|
|
"payload_bytes": 128,
|
|
}
|
|
|
|
monkeypatch.setattr("k1link.device_plugins.xgrids_k1.cli.capture_mqtt", fake_capture)
|
|
out = tmp_path / "capture"
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"net",
|
|
"mqtt-capture",
|
|
"--host",
|
|
"10.0.0.42",
|
|
"--out",
|
|
str(out),
|
|
"--duration",
|
|
"5",
|
|
"--max-message-bytes",
|
|
"1024",
|
|
"--confirm-owned-device",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert captured == {
|
|
"host": "10.0.0.42",
|
|
"out": out,
|
|
"port": 1883,
|
|
"duration_seconds": 5.0,
|
|
"max_message_bytes": 1024,
|
|
}
|
|
assert "messages: 2" in result.stdout
|
|
assert "subscriptions active" in result.stdout
|
|
|
|
|
|
@pytest.mark.parametrize("ledger_status", ["resolved", "corrupt"])
|
|
def test_cli_wifi_configure_blocks_retired_or_untrusted_audit_before_credentials_and_ble(
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
ledger_status: str,
|
|
) -> None:
|
|
repository_root = tmp_path / "repository"
|
|
repository_root.mkdir()
|
|
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
|
monkeypatch.setattr(cli, "_configure_ble_process_lease", lambda: repository_root)
|
|
retired_transport_ref = "F89438FA-55ED-85AD-EED7-734AC84746D8"
|
|
record = (
|
|
SimpleNamespace(
|
|
operator_retirements=(
|
|
SimpleNamespace(
|
|
retirement_id="retirement-cli-blocked-target",
|
|
retired_transport_ref=retired_transport_ref,
|
|
),
|
|
),
|
|
operator_reconciliation_reopens=(),
|
|
)
|
|
if ledger_status == "resolved"
|
|
else None
|
|
)
|
|
monkeypatch.setattr(
|
|
cli,
|
|
"PhysicalCommandLedger",
|
|
lambda _root: SimpleNamespace(
|
|
snapshot=lambda: SimpleNamespace(status=ledger_status, record=record)
|
|
),
|
|
)
|
|
calls: list[str] = []
|
|
monkeypatch.setattr(
|
|
cli,
|
|
"prompt_wifi_credentials",
|
|
lambda: calls.append("credentials") or ("ssid", "secret"),
|
|
)
|
|
|
|
async def forbidden_provision(*_args: object, **_kwargs: object) -> dict[str, object]:
|
|
calls.append("ble-write")
|
|
raise AssertionError("retired CLI target must not reach BLE")
|
|
|
|
monkeypatch.setattr(cli, "provision_wifi_once", forbidden_provision)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"ble",
|
|
"wifi-configure",
|
|
"--device",
|
|
retired_transport_ref.lower(),
|
|
"--out",
|
|
str(tmp_path / "wifi-result.json"),
|
|
"--profile",
|
|
cli.PROFILE_ID,
|
|
"--confirm-write",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 2
|
|
assert "blocked before credential or device access" in result.stdout
|
|
assert calls == []
|
|
assert not (tmp_path / "wifi-result.json").exists()
|