Files
nexus-sync/tests/test_client_runtime.py
T
2026-06-14 20:36:37 +03:00

238 lines
7.6 KiB
Python

import io
import urllib.error
import urllib.request
from datetime import UTC, datetime
import pytest
from nexus_sync.client.execute import CommandAccessPolicy
from nexus_sync.client.runtime import (
CLIENT_ID_ENV,
CLIENT_TOKEN_ENV,
SERVER_URL_ENV,
ClientConfig,
ClientConfigError,
HeartbeatError,
build_heartbeat_request,
load_client_config,
main,
run_once,
send_heartbeat,
)
from nexus_sync.common import (
Command,
CommandKind,
CommandResult,
CommandResultStatus,
HeartbeatRequest,
HeartbeatResponse,
)
def _config(policy: CommandAccessPolicy | None = None) -> ClientConfig:
return ClientConfig(
server_url="https://nexus.example.test",
client_id="macbook-pro-01",
token="client-token",
command_access_policy=policy or CommandAccessPolicy.deny_all(),
)
def test_load_client_config_reads_required_env_and_normalizes_server_url() -> None:
config = load_client_config(
{
SERVER_URL_ENV: "https://nexus.example.test/",
CLIENT_ID_ENV: "macbook-pro-01",
CLIENT_TOKEN_ENV: "client-token",
"NEXUS_SYNC_ALLOWED_COMMANDS": "hostname",
}
)
assert config.server_url == "https://nexus.example.test"
assert config.client_id == "macbook-pro-01"
assert config.token == "client-token"
assert config.command_access_policy.allows("hostname")
assert not config.command_access_policy.allows("network_interfaces")
@pytest.mark.parametrize("missing_name", [SERVER_URL_ENV, CLIENT_ID_ENV, CLIENT_TOKEN_ENV])
def test_load_client_config_requires_env_values(missing_name: str) -> None:
env = {
SERVER_URL_ENV: "https://nexus.example.test",
CLIENT_ID_ENV: "macbook-pro-01",
CLIENT_TOKEN_ENV: "client-token",
}
del env[missing_name]
with pytest.raises(ClientConfigError, match=missing_name):
load_client_config(env)
def test_build_heartbeat_request_contains_client_state(monkeypatch) -> None:
monkeypatch.setattr("socket.gethostname", lambda: "macbook-pro.local")
monkeypatch.setattr("platform.system", lambda: "Darwin")
heartbeat = build_heartbeat_request(_config())
serialized = heartbeat.model_dump(mode="json")
assert heartbeat.client_id == "macbook-pro-01"
assert heartbeat.client.hostname == "macbook-pro.local"
assert heartbeat.client.platform == "darwin"
assert heartbeat.client.version == "0.1.0"
assert heartbeat.last_command_result is None
assert serialized["client_id"] == "macbook-pro-01"
assert serialized["state"]["uptime_seconds"] is None
def test_send_heartbeat_posts_json_with_bearer_token() -> None:
captured = {}
def fake_opener(request: urllib.request.Request):
captured["url"] = request.full_url
captured["authorization"] = request.get_header("Authorization")
captured["content_type"] = request.get_header("Content-type")
captured["data"] = request.data
return _Response(
HeartbeatResponse(
server_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC),
next_poll_after_seconds=60,
command=None,
)
.model_dump_json()
.encode()
)
response = send_heartbeat(
_config(),
HeartbeatRequest(
client_id="macbook-pro-01",
observed_at=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC),
client={
"hostname": "macbook-pro.local",
"platform": "darwin",
"version": "0.1.0",
},
state={
"local_time": datetime(2026, 5, 24, 16, 20, 30, tzinfo=UTC),
"uptime_seconds": None,
},
last_command_result=None,
),
opener=fake_opener,
)
assert response.command is None
assert captured["url"] == "https://nexus.example.test/api/v1/client/heartbeat"
assert captured["authorization"] == "Bearer client-token"
assert captured["content_type"] == "application/json"
assert b"macbook-pro-01" in captured["data"]
def test_send_heartbeat_maps_http_error_to_runtime_error() -> None:
def fake_opener(_request: urllib.request.Request):
raise urllib.error.HTTPError(
url="https://nexus.example.test/api/v1/client/heartbeat",
code=401,
msg="Unauthorized",
hdrs={},
fp=io.BytesIO(b'{"detail":"invalid bearer token"}'),
)
with pytest.raises(HeartbeatError, match="HTTP 401"):
send_heartbeat(_config(), build_heartbeat_request(_config()), opener=fake_opener)
def test_run_once_without_command_does_not_call_executor() -> None:
executor_called = False
def fake_sender(_config: ClientConfig, _heartbeat: HeartbeatRequest) -> HeartbeatResponse:
return HeartbeatResponse(
server_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC),
next_poll_after_seconds=60,
command=None,
)
def fake_executor(*_args, **_kwargs):
nonlocal executor_called
executor_called = True
raise AssertionError("executor should not be called")
result = run_once(_config(), heartbeat_sender=fake_sender, executor=fake_executor)
assert result is None
assert executor_called is False
def test_run_once_executes_command_with_configured_access_policy() -> None:
policy = CommandAccessPolicy.allow(["hostname"])
seen = {}
def fake_sender(_config: ClientConfig, _heartbeat: HeartbeatRequest) -> HeartbeatResponse:
return HeartbeatResponse(
server_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC),
next_poll_after_seconds=10,
command=Command(
id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B",
kind=CommandKind.EXEC,
name="hostname",
args={},
timeout_seconds=30,
),
)
def fake_executor(command: Command, **kwargs) -> CommandResult:
seen["command"] = command
seen["access_policy"] = kwargs["access_policy"]
return CommandResult(
command_id=command.id,
status=CommandResultStatus.SUCCEEDED,
return_code=0,
stdout="host\n",
)
result = run_once(_config(policy), heartbeat_sender=fake_sender, executor=fake_executor)
assert result is not None
assert result.status == CommandResultStatus.SUCCEEDED
assert seen["command"].name == "hostname"
assert seen["access_policy"] == policy
def test_main_returns_non_zero_for_missing_config(monkeypatch, capsys) -> None:
monkeypatch.delenv(SERVER_URL_ENV, raising=False)
monkeypatch.delenv(CLIENT_ID_ENV, raising=False)
monkeypatch.delenv(CLIENT_TOKEN_ENV, raising=False)
exit_code = main([])
captured = capsys.readouterr()
assert exit_code == 1
assert SERVER_URL_ENV in captured.err
def test_main_prints_success_without_command(monkeypatch, capsys) -> None:
monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test")
monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01")
monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token")
monkeypatch.setattr("nexus_sync.client.runtime.run_once", lambda _config: None)
exit_code = main([])
captured = capsys.readouterr()
assert exit_code == 0
assert captured.out == "heartbeat accepted; no command\n"
class _Response:
def __init__(self, body: bytes) -> None:
self._body = body
def __enter__(self) -> "_Response":
return self
def __exit__(self, *_args) -> None:
return None
def read(self) -> bytes:
return self._body