Harden RF node control and agent updates
This commit is contained in:
625
tests/test_rf001_agent_launcher.py
Normal file
625
tests/test_rf001_agent_launcher.py
Normal file
@@ -0,0 +1,625 @@
|
||||
"""Software-only tests for the stable versioned RX-agent launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_agent_launcher as launcher
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, process_id: int, returncode: int | None = None) -> None:
|
||||
self.pid = process_id
|
||||
self.returncode = returncode
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return self.returncode
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.returncode = 0
|
||||
|
||||
def kill(self) -> None:
|
||||
self.returncode = -9
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
del timeout
|
||||
if self.returncode is None:
|
||||
self.returncode = 0
|
||||
return self.returncode
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 29, 13, 0, tzinfo=timezone.utc)
|
||||
WRAPPER = r"C:\runtime\.venv\Scripts\pythonw.exe"
|
||||
CHILD = r"C:\runtime\Python313\python.exe"
|
||||
AGENT = r"C:\release\app\tools\rf001_rx_http_agent.py"
|
||||
|
||||
|
||||
def _attempt(
|
||||
*,
|
||||
process_id: int = 200,
|
||||
nonce: str = "current-launch-nonce",
|
||||
returncode: int | None = None,
|
||||
) -> launcher.LaunchAttempt:
|
||||
command = (
|
||||
WRAPPER,
|
||||
"-u",
|
||||
"-B",
|
||||
"-X",
|
||||
"utf8",
|
||||
AGENT,
|
||||
"--launch-nonce",
|
||||
nonce,
|
||||
)
|
||||
return launcher.LaunchAttempt(
|
||||
process=FakeProcess(process_id, returncode), # type: ignore[arg-type]
|
||||
command=command,
|
||||
wrapper_pid=process_id,
|
||||
launch_boundary=NOW.isoformat(),
|
||||
wrapper_executable=WRAPPER,
|
||||
child_executable=CHILD,
|
||||
launch_nonce=nonce,
|
||||
)
|
||||
|
||||
|
||||
def _identity(
|
||||
pid: int,
|
||||
parent_pid: int,
|
||||
*,
|
||||
executable: str,
|
||||
command: tuple[str, ...] | None = None,
|
||||
seconds: float = 1.0,
|
||||
) -> launcher.ProcessIdentity:
|
||||
return launcher.ProcessIdentity(
|
||||
pid=pid,
|
||||
parent_pid=parent_pid,
|
||||
executable=executable,
|
||||
command_line=subprocess.list2cmdline(command or _attempt().command),
|
||||
created_at=(NOW + timedelta(seconds=seconds)).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _wrapper(attempt: launcher.LaunchAttempt | None = None) -> launcher.ProcessIdentity:
|
||||
value = attempt or _attempt()
|
||||
return _identity(
|
||||
value.wrapper_pid,
|
||||
100,
|
||||
executable=value.wrapper_executable,
|
||||
command=value.command,
|
||||
)
|
||||
|
||||
|
||||
def _child(attempt: launcher.LaunchAttempt | None = None) -> launcher.ProcessIdentity:
|
||||
value = attempt or _attempt()
|
||||
return _identity(
|
||||
value.wrapper_pid + 1,
|
||||
value.wrapper_pid,
|
||||
executable=value.child_executable,
|
||||
command=value.command,
|
||||
)
|
||||
|
||||
|
||||
def _health(
|
||||
*,
|
||||
pid: int = 200,
|
||||
release_id: str = "rf001-agent-2026.09.29.1",
|
||||
version: str = "2026.09.29.1",
|
||||
nonce: str = "current-launch-nonce",
|
||||
state: str = "RUNNING",
|
||||
updated_at: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"release_id": release_id,
|
||||
"agent_version": version,
|
||||
"launch_nonce": nonce,
|
||||
"state": state,
|
||||
"pid": pid,
|
||||
"updated_at": updated_at or (NOW + timedelta(seconds=2)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _config(tmp_path: Path) -> dict[str, object]:
|
||||
install = tmp_path / "install"
|
||||
config: dict[str, object] = {
|
||||
"install_root": str(install),
|
||||
"releases_root": str(install / "versions"),
|
||||
"active_release_file": str(install / "active_release.json"),
|
||||
"launcher_status": str(install / "launcher_status.json"),
|
||||
"launcher_request": str(install / "launcher_request.json"),
|
||||
"health_file": str(install / "agent_health.json"),
|
||||
"agent_log": str(install / "logs" / "agent.log"),
|
||||
"agent_arguments": [],
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
def _ready(config: dict[str, object], release_id: str, version: str) -> None:
|
||||
root = Path(str(config["releases_root"])) / release_id
|
||||
(root / "app" / "tools").mkdir(parents=True)
|
||||
(root / "app" / "tools" / "rf001_rx_http_agent.py").write_text(
|
||||
"# fixture\n", encoding="utf-8"
|
||||
)
|
||||
(root / "READY.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "READY",
|
||||
"release_id": release_id,
|
||||
"agent_version": version,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _request(
|
||||
config: dict[str, object],
|
||||
current: str,
|
||||
target: str,
|
||||
version: str,
|
||||
) -> dict[str, object]:
|
||||
value = {
|
||||
"task_id": "UPDATE_TEST_001",
|
||||
"from_release_id": current,
|
||||
"release_id": target,
|
||||
"agent_version": version,
|
||||
"safe_to_switch": True,
|
||||
}
|
||||
path = Path(str(config["launcher_request"]))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
Path(str(config["health_file"])).write_text(
|
||||
json.dumps({"busy": False, "active_task_id": None}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def test_launcher_switches_only_to_ready_release(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config = _config(tmp_path)
|
||||
old = "rf001-agent-2026.09.29.1"
|
||||
new = "rf001-agent-2026.09.29.2"
|
||||
_ready(config, old, "2026.09.29.1")
|
||||
_ready(config, new, "2026.09.29.2")
|
||||
request = _request(config, old, new, "2026.09.29.2")
|
||||
terminated: list[FakeProcess] = []
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"terminate_process",
|
||||
lambda process: terminated.append(process),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"start_release",
|
||||
lambda *_args, **_kwargs: (FakeProcess(2), "2026.09.29.2"),
|
||||
)
|
||||
process, release_id, version = launcher.switch_release(
|
||||
config,
|
||||
FakeProcess(1),
|
||||
old,
|
||||
"2026.09.29.1",
|
||||
request,
|
||||
)
|
||||
assert process.pid == 2
|
||||
assert release_id == new
|
||||
assert version == "2026.09.29.2"
|
||||
assert len(terminated) == 1
|
||||
active = json.loads(Path(str(config["active_release_file"])).read_text())
|
||||
assert active["release_id"] == new
|
||||
status = json.loads(Path(str(config["launcher_status"])).read_text())
|
||||
assert status["state"] == "RUNNING"
|
||||
assert status["phase"] == "TARGET_HEALTHY"
|
||||
assert status["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_launcher_rolls_back_after_new_version_start_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config = _config(tmp_path)
|
||||
old = "rf001-agent-2026.09.29.1"
|
||||
new = "rf001-agent-2026.09.29.2"
|
||||
_ready(config, old, "2026.09.29.1")
|
||||
_ready(config, new, "2026.09.29.2")
|
||||
request = _request(config, old, new, "2026.09.29.2")
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_start(
|
||||
_config_value: object,
|
||||
release_id: str,
|
||||
*,
|
||||
expected_version: str | None = None,
|
||||
) -> tuple[FakeProcess, str]:
|
||||
del expected_version
|
||||
calls.append(release_id)
|
||||
if release_id == new:
|
||||
raise RuntimeError("fixed startup failure")
|
||||
return FakeProcess(3), "2026.09.29.1"
|
||||
|
||||
monkeypatch.setattr(launcher, "terminate_process", lambda _process: None)
|
||||
monkeypatch.setattr(launcher, "start_release", fake_start)
|
||||
process, release_id, version = launcher.switch_release(
|
||||
config,
|
||||
FakeProcess(1),
|
||||
old,
|
||||
"2026.09.29.1",
|
||||
request,
|
||||
)
|
||||
assert process.pid == 3
|
||||
assert release_id == old
|
||||
assert version == "2026.09.29.1"
|
||||
assert calls == [new, old]
|
||||
active = json.loads(Path(str(config["active_release_file"])).read_text())
|
||||
assert active["release_id"] == old
|
||||
status = json.loads(Path(str(config["launcher_status"])).read_text())
|
||||
assert status["state"] == "ROLLED_BACK"
|
||||
assert status["phase"] == "LEGACY_RESTORED"
|
||||
assert "fixed startup failure" in status["error"]
|
||||
|
||||
|
||||
def test_launcher_rejects_busy_agent_without_stopping_it(tmp_path: Path) -> None:
|
||||
config = _config(tmp_path)
|
||||
old = "rf001-agent-2026.09.29.1"
|
||||
new = "rf001-agent-2026.09.29.2"
|
||||
_ready(config, old, "2026.09.29.1")
|
||||
_ready(config, new, "2026.09.29.2")
|
||||
request = _request(config, old, new, "2026.09.29.2")
|
||||
Path(str(config["health_file"])).write_text(
|
||||
json.dumps({"busy": True, "active_task_id": "STAIRCASE_TEST"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="not idle"):
|
||||
launcher.switch_release(
|
||||
config,
|
||||
FakeProcess(1),
|
||||
old,
|
||||
"2026.09.29.1",
|
||||
request,
|
||||
)
|
||||
|
||||
|
||||
def test_direct_exact_identity_passes() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
result = launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
attempt.wrapper_pid,
|
||||
[wrapper],
|
||||
)
|
||||
assert result.mode == "DIRECT"
|
||||
assert result.target == wrapper
|
||||
|
||||
|
||||
def test_unique_expected_wrapper_child_passes() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
result = launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
[wrapper, child],
|
||||
)
|
||||
assert result.mode == "WINDOWS_VENV_WRAPPER"
|
||||
assert result.target == child
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("launch_nonce", "wrong"),
|
||||
("release_id", "rf001-agent-2026.09.29.9"),
|
||||
("agent_version", "2026.09.29.9"),
|
||||
("state", "STARTING"),
|
||||
],
|
||||
)
|
||||
def test_wrong_health_field_fails(field: str, value: str) -> None:
|
||||
health = _health()
|
||||
health[field] = value
|
||||
assert not launcher._health_fields_match(
|
||||
health,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
launch_nonce="current-launch-nonce",
|
||||
launch_boundary=NOW.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def test_stale_health_fails() -> None:
|
||||
health = _health(updated_at=(NOW - timedelta(seconds=1)).isoformat())
|
||||
assert not launcher._health_fields_match(
|
||||
health,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
launch_nonce="current-launch-nonce",
|
||||
launch_boundary=NOW.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_pid_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
unrelated = _identity(300, 999, executable=CHILD)
|
||||
with pytest.raises(RuntimeError, match="not a direct wrapper child"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
unrelated.pid,
|
||||
[wrapper, unrelated],
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_child_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
unrelated = replace(
|
||||
_child(attempt),
|
||||
command_line="python.exe unrelated.py",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
unrelated.pid,
|
||||
[wrapper, unrelated],
|
||||
)
|
||||
|
||||
|
||||
def test_unexpected_grandchild_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
grandchild = replace(child, pid=202, parent_pid=child.pid)
|
||||
with pytest.raises(RuntimeError, match="not a direct wrapper child"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
grandchild.pid,
|
||||
[wrapper, child, grandchild],
|
||||
)
|
||||
|
||||
|
||||
def test_two_matching_children_fail() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
first = _child(attempt)
|
||||
second = replace(first, pid=first.pid + 1)
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
first.pid,
|
||||
[wrapper, first, second],
|
||||
)
|
||||
|
||||
|
||||
def test_creation_time_mismatch_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
stale_child = replace(
|
||||
_child(attempt),
|
||||
created_at=(NOW - timedelta(seconds=30)).isoformat(),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
stale_child.pid,
|
||||
[wrapper, stale_child],
|
||||
)
|
||||
|
||||
|
||||
def test_pid_reuse_between_validations_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
reused = replace(
|
||||
child,
|
||||
created_at=(NOW + timedelta(seconds=3)).isoformat(),
|
||||
)
|
||||
snapshots = iter(([wrapper, child], [wrapper, reused]))
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_query_process_identities",
|
||||
lambda *_args, **_kwargs: next(snapshots),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="changed during validation"):
|
||||
launcher._validate_target_twice(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
)
|
||||
|
||||
|
||||
def test_wrapper_dies_too_early() -> None:
|
||||
attempt = _attempt(returncode=1)
|
||||
wrapper = _wrapper(attempt)
|
||||
with pytest.raises(RuntimeError, match="wrapper died"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
wrapper.pid,
|
||||
[wrapper],
|
||||
)
|
||||
|
||||
|
||||
def test_child_dies_before_final_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
snapshots = iter(([wrapper, child], [wrapper]))
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_query_process_identities",
|
||||
lambda *_args, **_kwargs: next(snapshots),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="health process is absent"):
|
||||
launcher._validate_target_twice(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
)
|
||||
|
||||
|
||||
def test_wrong_executable_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = replace(_child(attempt), executable=r"C:\Other\python.exe")
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
[wrapper, child],
|
||||
)
|
||||
|
||||
|
||||
def test_wrong_command_line_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = replace(_child(attempt), command_line="python.exe other_agent.py")
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
[wrapper, child],
|
||||
)
|
||||
|
||||
|
||||
def test_cleanup_terminates_verified_child_and_wrapper_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
unrelated = _identity(300, 999, executable=CHILD)
|
||||
alive = {wrapper.pid: wrapper, child.pid: child, unrelated.pid: unrelated}
|
||||
terminated: list[int] = []
|
||||
|
||||
def query(wrapper_pid: int, health_pid: int | None = None) -> list[launcher.ProcessIdentity]:
|
||||
del health_pid
|
||||
return [
|
||||
identity
|
||||
for identity in alive.values()
|
||||
if identity.pid == wrapper_pid or identity.parent_pid == wrapper_pid
|
||||
]
|
||||
|
||||
def terminate_identity(identity: launcher.ProcessIdentity) -> str:
|
||||
terminated.append(identity.pid)
|
||||
alive.pop(identity.pid)
|
||||
return f"PID {identity.pid}: terminated"
|
||||
|
||||
def terminate_wrapper(process: FakeProcess) -> None:
|
||||
terminated.append(process.pid)
|
||||
alive.pop(process.pid)
|
||||
process.returncode = 0
|
||||
|
||||
monkeypatch.setattr(launcher, "_query_process_identities", query)
|
||||
monkeypatch.setattr(launcher, "_terminate_verified_identity", terminate_identity)
|
||||
monkeypatch.setattr(launcher, "terminate_process", terminate_wrapper)
|
||||
|
||||
result = launcher.strict_failure_cleanup(attempt, wrapper)
|
||||
|
||||
assert result.state == "COMPLETE"
|
||||
assert result.orphan_verified_target_processes == ()
|
||||
assert terminated == [child.pid, wrapper.pid]
|
||||
assert unrelated.pid in alive
|
||||
|
||||
|
||||
def test_cleanup_unknown_identity_kills_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
killed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_query_process_identities",
|
||||
lambda *_args, **_kwargs: [wrapper, child],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_terminate_verified_identity",
|
||||
lambda identity: killed.append(identity.pid),
|
||||
)
|
||||
|
||||
result = launcher.strict_failure_cleanup(attempt, None)
|
||||
|
||||
assert result.state == "CLEANUP_INCOMPLETE"
|
||||
assert killed == []
|
||||
|
||||
|
||||
def test_two_sequential_successful_starts_use_distinct_nonces(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config = _config(tmp_path)
|
||||
release_id = "rf001-agent-2026.09.29.1"
|
||||
_ready(config, release_id, "2026.09.29.1")
|
||||
nonces = iter(("first-nonce", "second-nonce"))
|
||||
process_ids = iter((200, 300))
|
||||
observed: list[tuple[int, str]] = []
|
||||
|
||||
monkeypatch.setattr(launcher.secrets, "token_hex", lambda _length: next(nonces))
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_runtime_executables",
|
||||
lambda _value: (WRAPPER, CHILD),
|
||||
)
|
||||
|
||||
def spawn(
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
log_stream: object,
|
||||
) -> FakeProcess:
|
||||
del cwd, log_stream
|
||||
process = FakeProcess(next(process_ids))
|
||||
nonce = command[command.index("--launch-nonce") + 1]
|
||||
Path(str(config["health_file"])).write_text(
|
||||
json.dumps(_health(pid=process.pid, nonce=nonce, updated_at="2099-01-01T00:00:00+00:00")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return process
|
||||
|
||||
def capture(attempt: launcher.LaunchAttempt, *, timeout_seconds: float) -> launcher.ProcessIdentity:
|
||||
del timeout_seconds
|
||||
return _identity(
|
||||
attempt.wrapper_pid,
|
||||
100,
|
||||
executable=WRAPPER,
|
||||
command=attempt.command,
|
||||
)
|
||||
|
||||
def validate(
|
||||
attempt: launcher.LaunchAttempt,
|
||||
wrapper: launcher.ProcessIdentity,
|
||||
health_pid: int,
|
||||
) -> launcher.ValidatedTarget:
|
||||
observed.append((health_pid, attempt.launch_nonce))
|
||||
return launcher.ValidatedTarget("DIRECT", wrapper, wrapper)
|
||||
|
||||
monkeypatch.setattr(launcher, "_spawn_agent", spawn)
|
||||
monkeypatch.setattr(launcher, "_capture_wrapper_identity", capture)
|
||||
monkeypatch.setattr(launcher, "_validate_target_twice", validate)
|
||||
|
||||
first, _ = launcher.start_release(config, release_id)
|
||||
second, _ = launcher.start_release(config, release_id)
|
||||
|
||||
assert first.pid == 200
|
||||
assert second.pid == 300
|
||||
assert observed == [(200, "first-nonce"), (300, "second-nonce")]
|
||||
172
tests/test_rf001_agent_update.py
Normal file
172
tests/test_rf001_agent_update.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Software-only tests for fixed RF-001 agent release archives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf001_agent_update import (
|
||||
CONTENT_MANIFEST_NAME,
|
||||
RELEASE_SOURCE_FILES,
|
||||
ReleaseValidationError,
|
||||
build_release_archive,
|
||||
canonical_json_bytes,
|
||||
inspect_release_archive,
|
||||
sign_update_manifest,
|
||||
verify_update_manifest_signature,
|
||||
)
|
||||
|
||||
|
||||
def _source_tree(root: Path, *, agent_version: str = "2026.09.29.1") -> None:
|
||||
for index, relative in enumerate(RELEASE_SOURCE_FILES.values()):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# fixed fixture {index}\n"
|
||||
if relative == RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]:
|
||||
content += f'AGENT_VERSION = "{agent_version}"\n'
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def test_release_roundtrip_uses_exact_allowlist(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
_source_tree(source)
|
||||
package = tmp_path / "rf001-agent-2026.09.29.1.zip"
|
||||
built = build_release_archive(
|
||||
source,
|
||||
package,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
)
|
||||
extraction = tmp_path / "extracted"
|
||||
checked = inspect_release_archive(
|
||||
package,
|
||||
expected_package_sha256=built.package_sha256,
|
||||
expected_package_bytes=built.package_bytes,
|
||||
expected_content_manifest_sha256=built.content_manifest_sha256,
|
||||
extraction_root=extraction,
|
||||
)
|
||||
assert checked == built
|
||||
assert {
|
||||
path.relative_to(extraction).as_posix()
|
||||
for path in extraction.rglob("*")
|
||||
if path.is_file()
|
||||
} == set(RELEASE_SOURCE_FILES) | {CONTENT_MANIFEST_NAME}
|
||||
|
||||
|
||||
def test_update_manifest_rejects_bad_signature() -> None:
|
||||
token = b"fixed-test-token-0123456789abcdef"
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"release_id": "rf001-agent-2026.09.29.1",
|
||||
"package_sha256": "a" * 64,
|
||||
}
|
||||
signature = sign_update_manifest(token, manifest)
|
||||
verify_update_manifest_signature(token, manifest, signature)
|
||||
with pytest.raises(ReleaseValidationError, match="mismatch"):
|
||||
verify_update_manifest_signature(token, manifest, "0" * 64)
|
||||
changed = dict(manifest, package_sha256="b" * 64)
|
||||
with pytest.raises(ReleaseValidationError, match="mismatch"):
|
||||
verify_update_manifest_signature(token, changed, signature)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe_name",
|
||||
(
|
||||
"../outside.py",
|
||||
"/absolute.py",
|
||||
"C:/absolute.py",
|
||||
"app\\tools\\backslash.py",
|
||||
),
|
||||
)
|
||||
def test_release_rejects_unsafe_paths(tmp_path: Path, unsafe_name: str) -> None:
|
||||
package = tmp_path / "unsafe.zip"
|
||||
with zipfile.ZipFile(package, "w") as archive:
|
||||
archive.writestr(unsafe_name, b"x")
|
||||
# zipfile normalizes a backslash to a slash before it can be inspected;
|
||||
# that spelling still cannot enter the fixed release allowlist.
|
||||
with pytest.raises(
|
||||
ReleaseValidationError,
|
||||
match="unsafe|drive-like|not allowlisted|manifest is absent",
|
||||
):
|
||||
inspect_release_archive(package)
|
||||
|
||||
|
||||
def test_release_rejects_symlink_and_duplicate_names(tmp_path: Path) -> None:
|
||||
symlink_package = tmp_path / "symlink.zip"
|
||||
with zipfile.ZipFile(symlink_package, "w") as archive:
|
||||
info = zipfile.ZipInfo("app/tools/link.py")
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
archive.writestr(info, b"target")
|
||||
with pytest.raises(ReleaseValidationError, match="links"):
|
||||
inspect_release_archive(symlink_package)
|
||||
|
||||
duplicate_package = tmp_path / "duplicate.zip"
|
||||
with zipfile.ZipFile(duplicate_package, "w") as archive:
|
||||
archive.writestr("app/tools/a.py", b"a")
|
||||
archive.writestr("APP/TOOLS/A.PY", b"b")
|
||||
with pytest.raises(ReleaseValidationError, match="duplicate"):
|
||||
inspect_release_archive(duplicate_package)
|
||||
|
||||
|
||||
def test_release_rejects_unsupported_protocol_and_sha(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
_source_tree(source)
|
||||
package = tmp_path / "release.zip"
|
||||
built = build_release_archive(
|
||||
source,
|
||||
package,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=2,
|
||||
)
|
||||
assert built.protocol_version == 2
|
||||
with pytest.raises(ReleaseValidationError, match="package SHA256"):
|
||||
inspect_release_archive(package, expected_package_sha256="0" * 64)
|
||||
|
||||
|
||||
def test_content_manifest_must_be_canonical(tmp_path: Path) -> None:
|
||||
package = tmp_path / "noncanonical.zip"
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"release_id": "rf001-agent-2026.09.29.1",
|
||||
"agent_version": "2026.09.29.1",
|
||||
"protocol_version": 1,
|
||||
"files": [],
|
||||
}
|
||||
assert canonical_json_bytes(manifest) != json.dumps(manifest).encode("utf-8")
|
||||
with zipfile.ZipFile(package, "w") as archive:
|
||||
archive.writestr(CONTENT_MANIFEST_NAME, json.dumps(manifest))
|
||||
with pytest.raises(ReleaseValidationError, match="canonical"):
|
||||
inspect_release_archive(package)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_version", "requested_version"),
|
||||
(
|
||||
("2026.09.29.2", "2026.09.29.1"),
|
||||
("2026.09.29.1", "2026.09.29.2"),
|
||||
),
|
||||
)
|
||||
def test_release_builder_rejects_version_mismatch_before_artifact(
|
||||
tmp_path: Path,
|
||||
source_version: str,
|
||||
requested_version: str,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
_source_tree(source, agent_version=source_version)
|
||||
package = tmp_path / "must-not-exist.zip"
|
||||
with pytest.raises(ReleaseValidationError, match="AGENT_VERSION|release_id"):
|
||||
build_release_archive(
|
||||
source,
|
||||
package,
|
||||
release_id=f"rf001-agent-{requested_version}",
|
||||
agent_version=requested_version,
|
||||
protocol_version=1,
|
||||
)
|
||||
assert not package.exists()
|
||||
133
tests/test_rf001_bootstrap_install_orchestrator.py
Normal file
133
tests/test_rf001_bootstrap_install_orchestrator.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Software-only regressions for external bootstrap install evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf001_bootstrap_install_orchestrator import (
|
||||
EvidencePathError,
|
||||
PackageValidationError,
|
||||
SIGNATURE_DOMAIN,
|
||||
run_installer,
|
||||
snapshot_tree,
|
||||
validate_pristine_package,
|
||||
)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _signed_package(tmp_path: Path) -> tuple[Path, Path]:
|
||||
package = tmp_path / "clean-bootstrap"
|
||||
package.mkdir()
|
||||
(package / "INSTALL_RX_AGENT.cmd").write_text("@echo off\n", encoding="utf-8")
|
||||
payload = package / "payload.bin"
|
||||
payload.write_bytes(b"fixed bootstrap payload")
|
||||
rows = []
|
||||
for path in sorted(item for item in package.rglob("*") if item.is_file()):
|
||||
rows.append(
|
||||
{
|
||||
"path": path.relative_to(package).as_posix(),
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"package_kind": "BOOTSTRAP",
|
||||
"bootstrap_id": "rf001-bootstrap-2099.01.01.1",
|
||||
"agent_release_id": "rf001-agent-2099.01.01.1",
|
||||
"agent_version": "2099.01.01.1",
|
||||
"files": rows,
|
||||
}
|
||||
manifest_bytes = (
|
||||
json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n"
|
||||
).encode("utf-8")
|
||||
(package / "BOOTSTRAP_MANIFEST.json").write_bytes(manifest_bytes)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
signature = hmac.new(
|
||||
token.read_text(encoding="utf-8").encode("utf-8"),
|
||||
SIGNATURE_DOMAIN + manifest_bytes,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
(package / "BOOTSTRAP_MANIFEST.hmac").write_text(
|
||||
signature + "\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
return package, token
|
||||
|
||||
|
||||
def test_pristine_package_uses_external_logs_and_remains_unchanged(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
package, token = _signed_package(tmp_path)
|
||||
evidence = tmp_path / "external-evidence"
|
||||
before = snapshot_tree(package)
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; print('installer stdout'); print('installer stderr', file=sys.stderr)",
|
||||
]
|
||||
|
||||
result = run_installer(package, evidence, token, command=command)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["package_content_unchanged_immediate"] is True
|
||||
assert result["package_content_unchanged_after"] is True
|
||||
assert result["package_metadata_unchanged_after"] is True
|
||||
assert snapshot_tree(package) == before
|
||||
assert (evidence / "install.stdout.txt").read_text().strip() == "installer stdout"
|
||||
assert (evidence / "install.stderr.txt").read_text().strip() == "installer stderr"
|
||||
assert not (package / "install.stdout.txt").exists()
|
||||
assert not (package / "install.stderr.txt").exists()
|
||||
preflight = json.loads((evidence / "package_preflight.json").read_text())
|
||||
assert preflight["hmac"] == "PASS"
|
||||
assert preflight["unexpected"] == 0
|
||||
assert preflight["missing"] == 0
|
||||
|
||||
|
||||
def test_contaminated_package_fails_closed_before_command(tmp_path: Path) -> None:
|
||||
package, token = _signed_package(tmp_path)
|
||||
(package / "install.stdout.txt").write_text("orchestration leak\n", encoding="utf-8")
|
||||
evidence = tmp_path / "external-evidence"
|
||||
command_marker = tmp_path / "command-ran.txt"
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
f"from pathlib import Path; Path({str(command_marker)!r}).write_text('ran')",
|
||||
]
|
||||
|
||||
with pytest.raises(PackageValidationError, match="allowlist mismatch"):
|
||||
run_installer(package, evidence, token, command=command)
|
||||
|
||||
assert not command_marker.exists()
|
||||
assert not (evidence / "install.stdout.txt").exists()
|
||||
assert not (evidence / "install.stderr.txt").exists()
|
||||
|
||||
|
||||
def test_evidence_directory_inside_package_is_rejected(tmp_path: Path) -> None:
|
||||
package, token = _signed_package(tmp_path)
|
||||
evidence = package / "runtime-evidence"
|
||||
|
||||
with pytest.raises(EvidencePathError, match="outside the package"):
|
||||
run_installer(package, evidence, token, command=[sys.executable, "-c", "pass"])
|
||||
|
||||
|
||||
def test_pristine_validation_reports_exact_allowlist(tmp_path: Path) -> None:
|
||||
package, token = _signed_package(tmp_path)
|
||||
|
||||
result = validate_pristine_package(package, token)
|
||||
|
||||
assert result["hmac"] == "PASS"
|
||||
assert result["manifest_file_count"] == 2
|
||||
assert result["package_file_count"] == 4
|
||||
assert result["unexpected"] == 0
|
||||
assert result["missing"] == 0
|
||||
319
tests/test_rf001_build_agent_packages.py
Normal file
319
tests/test_rf001_build_agent_packages.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Software-only tests for the authenticated bootstrap package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pathlib import PurePosixPath
|
||||
import shutil
|
||||
import subprocess
|
||||
import unicodedata
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_build_agent_packages as packages
|
||||
from tools.rf001_agent_update import (
|
||||
RELEASE_SOURCE_FILES,
|
||||
ReleaseValidationError,
|
||||
source_agent_version,
|
||||
)
|
||||
|
||||
|
||||
def _bootstrap_source(
|
||||
root: Path,
|
||||
*,
|
||||
agent_version: str = "2026.09.29.1",
|
||||
) -> Path:
|
||||
fixed = {
|
||||
"INSTALL_RX_AGENT.cmd": "@echo off\n",
|
||||
"UNINSTALL_RX_AGENT.cmd": "@echo off\n",
|
||||
"tools/rf001_bootstrap_readme.txt": "fixed bootstrap readme\n",
|
||||
"tools/rf001_agent_launcher.py": "# fixed launcher\n",
|
||||
"tools/install_rf001_rx_autostart.ps1": "# fixed installer\n",
|
||||
"tools/uninstall_rf001_rx_autostart.ps1": "# fixed uninstaller\n",
|
||||
}
|
||||
for name, content in fixed.items():
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
for index, relative in enumerate(RELEASE_SOURCE_FILES.values()):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# fixed release source {index}\n"
|
||||
if relative == RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]:
|
||||
content += f'AGENT_VERSION = "{agent_version}"\n'
|
||||
path.write_text(content, encoding="utf-8")
|
||||
reference = root / ".local/rf001_probe/lab065_reference_staircase.cs8"
|
||||
reference.parent.mkdir(parents=True, exist_ok=True)
|
||||
reference.write_bytes(b"fixed exact staircase fixture")
|
||||
return reference
|
||||
|
||||
|
||||
def _formed_installer_allowlist(release_id: str) -> list[str]:
|
||||
powershell = shutil.which("powershell.exe")
|
||||
if powershell is None:
|
||||
pytest.skip("Windows PowerShell is required to evaluate the installer allowlist")
|
||||
installer = Path(packages.__file__).with_name("install_rf001_rx_autostart.ps1")
|
||||
environment = os.environ.copy()
|
||||
environment["SDR_ROVER_INSTALLER_PATH"] = str(installer)
|
||||
environment["SDR_ROVER_RELEASE_ID"] = release_id
|
||||
command = r"""
|
||||
$ErrorActionPreference = "Stop"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$syntax = [Management.Automation.Language.Parser]::ParseFile(
|
||||
$env:SDR_ROVER_INSTALLER_PATH,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors
|
||||
)
|
||||
if ($parseErrors.Count -ne 0) {
|
||||
throw "Installer contains PowerShell parse errors"
|
||||
}
|
||||
$assignments = @($syntax.FindAll({
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.AssignmentStatementAst] -and
|
||||
$node.Left -is [Management.Automation.Language.VariableExpressionAst] -and
|
||||
$node.Left.VariablePath.UserPath -eq "expectedPaths"
|
||||
}, $true))
|
||||
if ($assignments.Count -ne 1) {
|
||||
throw "Installer expectedPaths assignment is absent or ambiguous"
|
||||
}
|
||||
$releasePrefix = "versions/$($env:SDR_ROVER_RELEASE_ID)/"
|
||||
. ([scriptblock]::Create($assignments[0].Extent.Text))
|
||||
ConvertTo-Json -Compress -InputObject @($expectedPaths)
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[powershell, "-NoProfile", "-NonInteractive", "-Command", command],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
env=environment,
|
||||
)
|
||||
value = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert isinstance(value, list)
|
||||
assert all(isinstance(path, str) for path in value)
|
||||
return value
|
||||
|
||||
|
||||
def test_bootstrap_is_authenticated_and_excludes_secret(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
built = packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
checked = packages.inspect_bootstrap_archive(package, token_path=token)
|
||||
assert checked == built
|
||||
assert checked.file_count == len(RELEASE_SOURCE_FILES) + 8
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
names = archive.namelist()
|
||||
manifest = json.loads(archive.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
ready = json.loads(
|
||||
archive.read("versions/rf001-agent-2026.09.29.1/READY.json")
|
||||
)
|
||||
assert not any("token" in name.casefold() for name in names)
|
||||
assert not any(
|
||||
b"fixed-test-token" in archive.read(name)
|
||||
for name in names
|
||||
)
|
||||
actual_version = source_agent_version(
|
||||
source / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]
|
||||
)
|
||||
assert manifest["agent_version"] == actual_version
|
||||
assert ready["agent_version"] == actual_version
|
||||
|
||||
|
||||
def test_new_bootstrap_preserves_existing_release_bytes(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
base_package = tmp_path / "bootstrap-4.zip"
|
||||
next_package = tmp_path / "bootstrap-5.zip"
|
||||
release_id = "rf001-agent-2026.09.29.1"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
base_package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.4",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
(source / "tools/rf001_agent_launcher.py").write_text(
|
||||
"# corrected launcher\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
next_package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.5",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
agent_release_bootstrap_id="rf001-bootstrap-2026.09.29.4",
|
||||
)
|
||||
|
||||
release_prefix = f"versions/{release_id}/"
|
||||
with zipfile.ZipFile(base_package) as base, zipfile.ZipFile(next_package) as new:
|
||||
release_names = sorted(
|
||||
name for name in base.namelist() if name.startswith(release_prefix)
|
||||
)
|
||||
assert release_names
|
||||
assert all(base.read(name) == new.read(name) for name in release_names)
|
||||
assert base.read("launcher/rf001_agent_launcher.py") != new.read(
|
||||
"launcher/rf001_agent_launcher.py"
|
||||
)
|
||||
manifest = json.loads(new.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
ready = json.loads(new.read(f"{release_prefix}READY.json"))
|
||||
assert manifest["bootstrap_id"] == "rf001-bootstrap-2026.09.29.5"
|
||||
assert (
|
||||
manifest["agent_release_bootstrap_id"]
|
||||
== "rf001-bootstrap-2026.09.29.4"
|
||||
)
|
||||
assert ready["bootstrap_id"] == "rf001-bootstrap-2026.09.29.4"
|
||||
|
||||
|
||||
def test_bootstrap_rejects_the_wrong_existing_secret(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
wrong = tmp_path / "wrong.txt"
|
||||
wrong.write_text("another-fixed-token-0123456789abcdef", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="signature mismatch"):
|
||||
packages.inspect_bootstrap_archive(package, token_path=wrong)
|
||||
|
||||
|
||||
def test_installer_allowlist_matches_signed_manifest_and_has_safe_paths(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
release_id = "rf001-agent-2026.09.29.1"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.2",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
manifest = json.loads(archive.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
manifest_paths = {row["path"] for row in manifest["files"]}
|
||||
formed_paths = _formed_installer_allowlist(release_id)
|
||||
|
||||
assert set(formed_paths) == manifest_paths
|
||||
assert len(formed_paths) == len(manifest_paths)
|
||||
assert f"versions/{release_id}/READY.json" in formed_paths
|
||||
assert (
|
||||
f"versions/{release_id}/app/tools/hackrf_process_check.py" in formed_paths
|
||||
)
|
||||
assert (
|
||||
f"versions/{release_id}/app/tools/rf001_staircase_search.py" in formed_paths
|
||||
)
|
||||
for path in formed_paths:
|
||||
assert not any(unicodedata.category(character) == "Cc" for character in path)
|
||||
parsed = PurePosixPath(path)
|
||||
assert not parsed.is_absolute()
|
||||
assert all(part not in ("", ".", "..") for part in parsed.parts)
|
||||
assert "\\" not in path
|
||||
assert not any(":" in part for part in parsed.parts)
|
||||
|
||||
with zipfile.ZipFile(package, "a") as archive:
|
||||
archive.writestr("unexpected/extra.txt", b"prohibited")
|
||||
with pytest.raises(ValueError, match="unexpected files"):
|
||||
packages.inspect_bootstrap_archive(package, token_path=token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_version", "requested_version"),
|
||||
(
|
||||
("2026.09.29.2", "2026.09.29.1"),
|
||||
("2026.09.29.1", "2026.09.29.2"),
|
||||
),
|
||||
)
|
||||
def test_bootstrap_rejects_agent_version_mismatch_before_creating_artifact(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
source_version: str,
|
||||
requested_version: str,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source, agent_version=source_version)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "must-not-exist.zip"
|
||||
with pytest.raises(ReleaseValidationError, match="AGENT_VERSION|release_id"):
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.3",
|
||||
agent_release_id=f"rf001-agent-{requested_version}",
|
||||
agent_version=requested_version,
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
assert not package.exists()
|
||||
48
tests/test_rf001_capture_runner.py
Normal file
48
tests/test_rf001_capture_runner.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Software-only process evidence tests for the Python RX runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from tools.rf001_capture_runner import run_capture
|
||||
|
||||
|
||||
def test_python_runner_persists_child_exit_and_hash(tmp_path: Path) -> None:
|
||||
child = tmp_path / "child.py"
|
||||
capture = tmp_path / "capture.cs8"
|
||||
capture_bytes = b"software-only-capture-evidence\x00\x01"
|
||||
child.write_text(
|
||||
"from pathlib import Path\n"
|
||||
f"Path({str(capture)!r}).write_bytes({capture_bytes!r})\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
arguments = tmp_path / "arguments.json"
|
||||
arguments.write_text(json.dumps(["-B", str(child)]), encoding="utf-8")
|
||||
stdout = tmp_path / "stdout.txt"
|
||||
stderr = tmp_path / "stderr.txt"
|
||||
started = tmp_path / "started.json"
|
||||
result = tmp_path / "result.json"
|
||||
|
||||
exit_code = run_capture(
|
||||
Path(sys.executable),
|
||||
arguments,
|
||||
capture,
|
||||
stdout,
|
||||
stderr,
|
||||
started,
|
||||
result,
|
||||
)
|
||||
|
||||
start_record = json.loads(started.read_text(encoding="utf-8"))
|
||||
final_record = json.loads(result.read_text(encoding="utf-8"))
|
||||
assert exit_code == 0
|
||||
assert start_record["state"] == "RUNNING"
|
||||
assert start_record["rf_tx"] == 0
|
||||
assert final_record["exit_code"] == 0
|
||||
assert final_record["capture_size"] == len(capture_bytes)
|
||||
assert final_record["sha256"] == hashlib.sha256(capture_bytes).hexdigest()
|
||||
assert final_record["runner_error"] is None
|
||||
assert final_record["rf_tx"] == 0
|
||||
537
tests/test_rf001_http_control.py
Normal file
537
tests/test_rf001_http_control.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""Software-only tests for the RF-001 HTTP control channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_http_control as control_module
|
||||
from tools.rf001_http_control import (
|
||||
AuthenticationError,
|
||||
ControlHTTPServer,
|
||||
ControlState,
|
||||
PROTOCOL_VERSION,
|
||||
ProtocolError,
|
||||
RequestAuthenticator,
|
||||
canonical_json,
|
||||
request_json,
|
||||
signed_headers,
|
||||
)
|
||||
|
||||
|
||||
TOKEN = b"software-only-test-token-0123456789abcdef0123456789abcdef"
|
||||
|
||||
|
||||
def test_bootstrap_registration_uses_distinct_fixed_identifier(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
releases = tmp_path / "releases"
|
||||
releases.mkdir()
|
||||
package = releases / "rf001-bootstrap-2026.09.29.1.zip"
|
||||
package.write_bytes(b"fixed bootstrap package")
|
||||
monkeypatch.setattr(control_module, "DEFAULT_RELEASES_ROOT", releases)
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
registered = state.register_bootstrap_release(
|
||||
str(package),
|
||||
release_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
content_manifest_sha256="a" * 64,
|
||||
token=TOKEN,
|
||||
)
|
||||
assert registered["package_kind"] == "BOOTSTRAP"
|
||||
assert registered["rf_tx"] == 0
|
||||
replacement = releases / "replacement.zip"
|
||||
replacement.write_bytes(b"different bootstrap package")
|
||||
with pytest.raises(ProtocolError, match="already registered differently"):
|
||||
state.register_bootstrap_release(
|
||||
str(replacement),
|
||||
release_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
content_manifest_sha256="b" * 64,
|
||||
token=TOKEN,
|
||||
)
|
||||
with pytest.raises(ProtocolError, match="release_id"):
|
||||
state.register_bootstrap_release(
|
||||
str(package),
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
content_manifest_sha256="a" * 64,
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
|
||||
def test_request_authentication_rejects_replay_and_tampering() -> None:
|
||||
authenticator = RequestAuthenticator(TOKEN, window_seconds=60)
|
||||
body = canonical_json({"agent_id": "rx-user-pc", "rf_tx": 0})
|
||||
target = "/api/v1/agent/check-in"
|
||||
headers = signed_headers(
|
||||
TOKEN,
|
||||
"POST",
|
||||
target,
|
||||
body,
|
||||
timestamp=1_000,
|
||||
nonce="a" * 32,
|
||||
)
|
||||
|
||||
authenticator.verify("POST", target, body, headers, now=1_000)
|
||||
with pytest.raises(AuthenticationError, match="already used"):
|
||||
authenticator.verify("POST", target, body, headers, now=1_000)
|
||||
|
||||
changed_headers = signed_headers(
|
||||
TOKEN,
|
||||
"POST",
|
||||
target,
|
||||
body,
|
||||
timestamp=1_000,
|
||||
nonce="b" * 32,
|
||||
)
|
||||
with pytest.raises(AuthenticationError, match="mismatch"):
|
||||
authenticator.verify(
|
||||
"POST",
|
||||
target,
|
||||
body + b" ",
|
||||
changed_headers,
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
|
||||
def test_state_leases_inventory_and_records_idempotent_result(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_inventory("rx-user-pc", include_hackrf_info=True)
|
||||
response = state.check_in(
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
)
|
||||
assert response["task"] == task
|
||||
assert response["rf_tx"] == 0
|
||||
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "COMPLETE",
|
||||
"started_at": "2026-09-23T00:00:00+00:00",
|
||||
"finished_at": "2026-09-23T00:00:01+00:00",
|
||||
"result": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"errors": [],
|
||||
"rf_tx": 0,
|
||||
}
|
||||
accepted = state.record_result(result)
|
||||
assert accepted == {"accepted": True, "duplicate": False, "rf_tx": 0}
|
||||
duplicate = state.record_result(result)
|
||||
assert duplicate == {"accepted": True, "duplicate": True, "rf_tx": 0}
|
||||
|
||||
conflicting = dict(result)
|
||||
conflicting["status"] = "FAILED"
|
||||
with pytest.raises(ProtocolError, match="conflicting"):
|
||||
state.record_result(conflicting)
|
||||
|
||||
|
||||
def test_state_leases_fixed_offline_verify_task(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
expected_head = "a" * 40
|
||||
task = state.enqueue_offline_verify(
|
||||
"rx-user-pc",
|
||||
expected_head=expected_head,
|
||||
)
|
||||
response = state.check_in(
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
)
|
||||
assert response["task"] == task
|
||||
assert task["kind"] == "OFFLINE_VERIFY"
|
||||
assert task["expected_head"] == expected_head
|
||||
assert task["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_offline_verify_rejects_invalid_expected_head(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
with pytest.raises(ProtocolError, match="expected_head"):
|
||||
state.enqueue_offline_verify("rx-user-pc", expected_head="main")
|
||||
|
||||
|
||||
def test_rx_capture_requires_exact_local_authorization(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
with pytest.raises(ProtocolError, match="exact RX authorization"):
|
||||
state.enqueue_rx_capture(
|
||||
"rx-user-pc",
|
||||
expected_head="a" * 40,
|
||||
authorization="yes",
|
||||
)
|
||||
|
||||
task = state.enqueue_rx_capture(
|
||||
"rx-user-pc",
|
||||
expected_head="a" * 40,
|
||||
authorization="AUTHORIZE RF RX",
|
||||
)
|
||||
assert task["kind"] == "RX_CAPTURE_START"
|
||||
assert task["rx_serial"] == "000000000000000097c063dc2e5327d3"
|
||||
assert task["rx_authorized"] is True
|
||||
assert task["automatic_retry"] is False
|
||||
assert task["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_expired_rx_capture_lease_is_not_retried(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_rx_capture(
|
||||
"rx-user-pc",
|
||||
expected_head="a" * 40,
|
||||
authorization="AUTHORIZE RF RX",
|
||||
)
|
||||
check_in = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
|
||||
first = state.check_in(check_in)
|
||||
assert first["task"] == task
|
||||
|
||||
state._data["tasks"][0]["leased_at_epoch"] = int(time.time()) - 31
|
||||
second = state.check_in(check_in)
|
||||
|
||||
assert second["task"] is None
|
||||
stored = state.snapshot()["tasks"][0]
|
||||
assert stored["state"] == "LEASE_EXPIRED"
|
||||
assert "lease_expired_at" in stored
|
||||
assert stored["task_id"] == task["task_id"]
|
||||
|
||||
|
||||
def test_status_and_decode_tasks_use_fixed_session(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
session_id = "RF001A_20260923T150000Z_1234ABCD"
|
||||
status = state.enqueue_rx_status("rx-user-pc", session_id=session_id)
|
||||
decode = state.enqueue_capture_decode(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
tx_started_at="2026-09-23T15:00:05+00:00",
|
||||
tx_finished_at="2026-09-23T15:00:07+00:00",
|
||||
)
|
||||
diagnostics = state.enqueue_capture_diagnostics(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
tx_started_at="2026-09-23T15:00:05+00:00",
|
||||
tx_finished_at="2026-09-23T15:00:07+00:00",
|
||||
)
|
||||
assert status["kind"] == "RX_CAPTURE_STATUS"
|
||||
assert decode["kind"] == "CAPTURE_DECODE"
|
||||
assert diagnostics["kind"] == "CAPTURE_DIAGNOSTICS"
|
||||
assert status["session_id"] == decode["session_id"] == session_id
|
||||
assert diagnostics["session_id"] == session_id
|
||||
assert status["rf_tx"] == decode["rf_tx"] == diagnostics["rf_tx"] == 0
|
||||
|
||||
with pytest.raises(ProtocolError, match="invalid session_id"):
|
||||
state.enqueue_rx_status("rx-user-pc", session_id="..\\outside")
|
||||
|
||||
|
||||
def test_staircase_task_has_only_fixed_hashes_and_no_retry(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
session_id = "RF001A_20260925T125829Z_227EADB4"
|
||||
task = state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
assert task["kind"] == "STAIRCASE_SEARCH"
|
||||
assert task["session_id"] == session_id
|
||||
assert task["expected_rx_sha256"] == "2" * 64
|
||||
assert task["expected_reference_sha256"] == "f" * 64
|
||||
assert task["hardware_accessed"] is False
|
||||
assert task["automatic_retry"] is False
|
||||
assert task["rf_tx"] == 0
|
||||
assert "capture_path" not in task
|
||||
assert "reference_path" not in task
|
||||
assert "command" not in task
|
||||
|
||||
with pytest.raises(ProtocolError, match="invalid session_id"):
|
||||
state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id="..\\outside",
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
with pytest.raises(ProtocolError, match="expected_rx_sha256"):
|
||||
state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
expected_rx_sha256="bad",
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
with pytest.raises(ProtocolError, match="expected_reference_sha256"):
|
||||
state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="bad",
|
||||
)
|
||||
|
||||
|
||||
def test_long_task_heartbeat_renews_expired_lease_without_reissue(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id="RF001A_20260925T125829Z_227EADB4",
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
check_in = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"agent_version": "test", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
assert state.check_in(check_in)["task"] == task
|
||||
state._data["tasks"][0]["leased_at_epoch"] = int(time.time()) - 300
|
||||
active = {
|
||||
**check_in,
|
||||
"active_task": {
|
||||
"task_id": task["task_id"],
|
||||
"kind": "STAIRCASE_SEARCH",
|
||||
"state": "RUNNING",
|
||||
"phase": "SEARCHING",
|
||||
"processed_samples": 400,
|
||||
"total_samples": 1000,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
},
|
||||
}
|
||||
response = state.check_in(active)
|
||||
assert response["task"] is None
|
||||
stored = state.snapshot()["tasks"][0]
|
||||
assert stored["state"] == "LEASED"
|
||||
assert stored["progress"]["processed_samples"] == 400
|
||||
|
||||
|
||||
def test_update_approval_is_bound_to_registered_release_and_not_repeated(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
release_id = "rf001-agent-2026.09.29.2"
|
||||
state._data["releases"][release_id] = {
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"release_id": release_id,
|
||||
"agent_version": "2026.09.29.2",
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"package_kind": "AGENT_RELEASE",
|
||||
"package_sha256": "a" * 64,
|
||||
"package_bytes": 1234,
|
||||
"content_manifest_sha256": "b" * 64,
|
||||
},
|
||||
"manifest_signature": "c" * 64,
|
||||
"package_path": str(tmp_path / "release.zip"),
|
||||
}
|
||||
task = state.enqueue_agent_update(
|
||||
"rx-user-pc",
|
||||
release_id=release_id,
|
||||
validity_seconds=3600,
|
||||
)
|
||||
assert task["kind"] == "UPDATE_AGENT"
|
||||
assert task["release_id"] == release_id
|
||||
assert task["package_sha256"] == "a" * 64
|
||||
assert task["automatic_retry"] is False
|
||||
assert task["hardware_accessed"] is False
|
||||
assert task["rf_tx"] == 0
|
||||
with pytest.raises(ProtocolError, match="already has an update task"):
|
||||
state.enqueue_agent_update(
|
||||
"rx-user-pc",
|
||||
release_id=release_id,
|
||||
validity_seconds=3600,
|
||||
)
|
||||
|
||||
|
||||
def test_queued_update_waits_for_active_software_task(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
running = state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id="RF001A_20260925T125829Z_227EADB4",
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
check_in = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"agent_version": "test", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
assert state.check_in(check_in)["task"] == running
|
||||
release_id = "rf001-agent-2026.09.29.2"
|
||||
state._data["releases"][release_id] = {
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"release_id": release_id,
|
||||
"agent_version": "2026.09.29.2",
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"package_kind": "AGENT_RELEASE",
|
||||
"package_sha256": "a" * 64,
|
||||
"package_bytes": 1234,
|
||||
"content_manifest_sha256": "b" * 64,
|
||||
},
|
||||
"manifest_signature": "c" * 64,
|
||||
"package_path": str(tmp_path / "release.zip"),
|
||||
}
|
||||
update = state.enqueue_agent_update(
|
||||
"rx-user-pc", release_id=release_id, validity_seconds=3600
|
||||
)
|
||||
response = state.check_in(
|
||||
{
|
||||
**check_in,
|
||||
"active_task": {
|
||||
"task_id": running["task_id"],
|
||||
"kind": "STAIRCASE_SEARCH",
|
||||
"state": "RUNNING",
|
||||
"phase": "SEARCHING",
|
||||
"processed_samples": 1,
|
||||
"total_samples": 10,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert response["task"] is None
|
||||
stored = next(
|
||||
task
|
||||
for task in state.snapshot()["tasks"]
|
||||
if task["task_id"] == update["task_id"]
|
||||
)
|
||||
assert stored["state"] == "QUEUED"
|
||||
assert stored["update_status"] == "WAITING_FOR_IDLE"
|
||||
assert stored["blocked_by_task_id"] == running["task_id"]
|
||||
|
||||
|
||||
def test_http_server_end_to_end_inventory_without_rf(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
server = ControlHTTPServer(
|
||||
("127.0.0.1", 0),
|
||||
state=state,
|
||||
token=TOKEN,
|
||||
allowed_agent_ip="127.0.0.1",
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base_url = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
try:
|
||||
task = request_json(
|
||||
base_url,
|
||||
TOKEN,
|
||||
"POST",
|
||||
"/api/v1/local/tasks/inventory",
|
||||
{"agent_id": "rx-user-pc", "include_hackrf_info": True},
|
||||
)
|
||||
assert task["kind"] == "INVENTORY"
|
||||
assert task["rf_tx"] == 0
|
||||
|
||||
check_in = request_json(
|
||||
base_url,
|
||||
TOKEN,
|
||||
"POST",
|
||||
"/api/v1/agent/check-in",
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
},
|
||||
)
|
||||
assert check_in["task"] == task
|
||||
assert check_in["rf_tx"] == 0
|
||||
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "COMPLETE",
|
||||
"started_at": "2026-09-23T00:00:00+00:00",
|
||||
"finished_at": "2026-09-23T00:00:01+00:00",
|
||||
"result": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"errors": [],
|
||||
"rf_tx": 0,
|
||||
}
|
||||
acknowledgement = request_json(
|
||||
base_url,
|
||||
TOKEN,
|
||||
"POST",
|
||||
"/api/v1/agent/result",
|
||||
result,
|
||||
)
|
||||
assert acknowledgement["accepted"] is True
|
||||
assert acknowledgement["rf_tx"] == 0
|
||||
|
||||
status = request_json(
|
||||
base_url,
|
||||
TOKEN,
|
||||
"GET",
|
||||
"/api/v1/local/status",
|
||||
)
|
||||
assert status["results"][task["task_id"]] == result
|
||||
assert status["tasks"][0]["state"] == "COMPLETE"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_result_cannot_claim_rf_tx(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_inventory("rx-user-pc", include_hackrf_info=False)
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "COMPLETE",
|
||||
"result": {},
|
||||
"errors": [],
|
||||
"rf_tx": 1,
|
||||
}
|
||||
with pytest.raises(ProtocolError, match="rf_tx = 0"):
|
||||
state.record_result(result)
|
||||
|
||||
|
||||
def test_cancelled_task_is_not_released_or_completed(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_rx_capture(
|
||||
"rx-user-pc",
|
||||
expected_head="a" * 40,
|
||||
authorization="AUTHORIZE RF RX",
|
||||
)
|
||||
cancellation = state.cancel_task(
|
||||
task["task_id"],
|
||||
reason="software packaging failure before hardware access",
|
||||
)
|
||||
assert cancellation["state"] == "CANCELLED"
|
||||
assert cancellation["rf_tx"] == 0
|
||||
|
||||
response = state.check_in(
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
)
|
||||
assert response["task"] is None
|
||||
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "FAILED",
|
||||
"result": {"hardware_accessed": False, "rf_tx": 0},
|
||||
"errors": ["packaging failure"],
|
||||
"rf_tx": 0,
|
||||
}
|
||||
with pytest.raises(ProtocolError, match="cancelled"):
|
||||
state.record_result(result)
|
||||
429
tests/test_rf001_installer_transaction.py
Normal file
429
tests/test_rf001_installer_transaction.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""Software-only checks for transactional bootstrap target deployment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BOOTSTRAP_ID = "rf001-bootstrap-2026.09.29.4"
|
||||
RELEASE_ID = "rf001-agent-2026.09.29.1"
|
||||
AGENT_VERSION = "2026.09.29.1"
|
||||
RELEASE_PREFIX = f"versions/{RELEASE_ID}/"
|
||||
FUNCTIONS = (
|
||||
"Get-LowerSha256",
|
||||
"Assert-ExactReleasePath",
|
||||
"Get-ReleaseDirectoryState",
|
||||
"Assert-ReleaseMetadata",
|
||||
"New-UniqueQuarantineDestination",
|
||||
"Write-CreateNewUtf8Json",
|
||||
"Move-ConflictingTargetToQuarantine",
|
||||
"Move-FailedReleasePathToQuarantine",
|
||||
"Install-CleanRelease",
|
||||
"Prepare-TargetRelease",
|
||||
)
|
||||
|
||||
|
||||
def _powershell() -> str:
|
||||
executable = shutil.which("powershell.exe")
|
||||
if executable is None:
|
||||
pytest.skip("Windows PowerShell is required for installer transaction tests")
|
||||
return executable
|
||||
|
||||
|
||||
def _installer() -> Path:
|
||||
return Path(__file__).parents[1] / "tools" / "install_rf001_rx_autostart.ps1"
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _release_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
|
||||
install_root = tmp_path / "install"
|
||||
versions_root = install_root / "versions"
|
||||
target = versions_root / RELEASE_ID
|
||||
source = tmp_path / "signed-bootstrap" / "versions" / RELEASE_ID
|
||||
tools = source / "app" / "tools"
|
||||
tools.mkdir(parents=True)
|
||||
(tools / "rf001_rx_http_agent.py").write_text(
|
||||
f'AGENT_VERSION = "{AGENT_VERSION}"\n', encoding="utf-8"
|
||||
)
|
||||
(tools / "rf001_agent_update.py").write_text(
|
||||
"# fixed signed updater fixture\n", encoding="utf-8"
|
||||
)
|
||||
ready = {
|
||||
"agent_version": AGENT_VERSION,
|
||||
"bootstrap_id": BOOTSTRAP_ID,
|
||||
"hardware_accessed": False,
|
||||
"protocol_version": 1,
|
||||
"release_id": RELEASE_ID,
|
||||
"rf_tx": 0,
|
||||
"schema_version": 1,
|
||||
"status": "READY",
|
||||
}
|
||||
(source / "READY.json").write_text(
|
||||
json.dumps(ready, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rows = []
|
||||
for path in sorted(item for item in source.rglob("*") if item.is_file()):
|
||||
relative = path.relative_to(source).as_posix()
|
||||
rows.append(
|
||||
{
|
||||
"path": RELEASE_PREFIX + relative,
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
manifest = tmp_path / "release_rows.json"
|
||||
manifest.write_text(json.dumps(rows), encoding="utf-8")
|
||||
versions_root.mkdir(parents=True)
|
||||
return source, install_root, target, manifest
|
||||
|
||||
|
||||
def _function_loader() -> str:
|
||||
names = ",".join(f'"{name}"' for name in FUNCTIONS)
|
||||
return rf"""
|
||||
$ErrorActionPreference = "Stop"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$syntax = [Management.Automation.Language.Parser]::ParseFile(
|
||||
$env:RF001_INSTALLER,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors
|
||||
)
|
||||
if ($parseErrors.Count -ne 0) {{
|
||||
throw "Installer contains PowerShell parse errors"
|
||||
}}
|
||||
foreach ($name in @({names})) {{
|
||||
$matches = @($syntax.FindAll({{
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -eq $name
|
||||
}}, $true))
|
||||
if ($matches.Count -ne 1) {{
|
||||
throw "Installer function is absent or ambiguous: $name"
|
||||
}}
|
||||
. ([scriptblock]::Create($matches[0].Extent.Text))
|
||||
}}
|
||||
$releaseRows = @(Get-Content -LiteralPath $env:RF001_ROWS -Raw -Encoding UTF8 | ConvertFrom-Json)
|
||||
$installRoot = $env:RF001_INSTALL_ROOT
|
||||
$versionsRoot = Join-Path $installRoot "versions"
|
||||
$sourceRelease = $env:RF001_SOURCE_RELEASE
|
||||
$targetRelease = Join-Path $versionsRoot "{RELEASE_ID}"
|
||||
"""
|
||||
|
||||
|
||||
def _run_powershell(
|
||||
body: str,
|
||||
*,
|
||||
source: Path,
|
||||
install_root: Path,
|
||||
manifest: Path,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"RF001_INSTALLER": str(_installer()),
|
||||
"RF001_ROWS": str(manifest),
|
||||
"RF001_INSTALL_ROOT": str(install_root),
|
||||
"RF001_SOURCE_RELEASE": str(source),
|
||||
}
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[_powershell(), "-NoProfile", "-NonInteractive", "-Command", _function_loader() + body],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
env=environment,
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"PowerShell transaction harness failed ({completed.returncode}):\n"
|
||||
f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
|
||||
)
|
||||
return completed
|
||||
|
||||
|
||||
def _prepare(
|
||||
source: Path,
|
||||
install_root: Path,
|
||||
manifest: Path,
|
||||
) -> dict[str, object]:
|
||||
body = rf"""
|
||||
$result = Prepare-TargetRelease `
|
||||
-InstallRoot $installRoot `
|
||||
-VersionsRoot $versionsRoot `
|
||||
-SourceRelease $sourceRelease `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseId "{RELEASE_ID}" `
|
||||
-BootstrapId "{BOOTSTRAP_ID}" `
|
||||
-AgentVersion "{AGENT_VERSION}" `
|
||||
-ProtocolVersion 1 `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix "{RELEASE_PREFIX}"
|
||||
$final = Get-ReleaseDirectoryState `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix "{RELEASE_PREFIX}"
|
||||
[ordered]@{{
|
||||
result = $result
|
||||
final_state = $final.state
|
||||
}} | ConvertTo-Json -Depth 10 -Compress
|
||||
"""
|
||||
completed = _run_powershell(
|
||||
body,
|
||||
source=source,
|
||||
install_root=install_root,
|
||||
manifest=manifest,
|
||||
)
|
||||
value = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert isinstance(value, dict)
|
||||
return value
|
||||
|
||||
|
||||
def _snapshot(root: Path) -> dict[str, tuple[int, int, str]]:
|
||||
return {
|
||||
path.relative_to(root).as_posix(): (
|
||||
path.stat().st_size,
|
||||
path.stat().st_mtime_ns,
|
||||
_sha256(path),
|
||||
)
|
||||
for path in root.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
|
||||
def _assert_matches_manifest(target: Path, manifest: Path) -> None:
|
||||
rows = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
expected = {
|
||||
str(row["path"])[len(RELEASE_PREFIX) :]: (
|
||||
int(row["bytes"]),
|
||||
str(row["sha256"]),
|
||||
)
|
||||
for row in rows
|
||||
}
|
||||
actual = {
|
||||
path.relative_to(target).as_posix(): (path.stat().st_size, _sha256(path))
|
||||
for path in target.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_absent_target_is_deployed_from_signed_manifest(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
report = _prepare(source, install_root, manifest)
|
||||
assert report["result"]["initial_state"] == "TARGET_ABSENT"
|
||||
assert report["result"]["reused"] is False
|
||||
assert report["final_state"] == "TARGET_VERIFIED"
|
||||
_assert_matches_manifest(target, manifest)
|
||||
|
||||
|
||||
def test_exact_target_is_reused_without_writing_bytes(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
before = _snapshot(target)
|
||||
report = _prepare(source, install_root, manifest)
|
||||
assert report["result"]["initial_state"] == "TARGET_VERIFIED"
|
||||
assert report["result"]["reused"] is True
|
||||
assert _snapshot(target) == before
|
||||
|
||||
|
||||
def test_mixed_release_is_quarantined_before_clean_deployment(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
agent = target / "app" / "tools" / "rf001_rx_http_agent.py"
|
||||
agent.write_text('AGENT_VERSION = "2026.09.29.2"\n', encoding="utf-8")
|
||||
ready = json.loads((target / "READY.json").read_text(encoding="utf-8"))
|
||||
ready["bootstrap_id"] = "rf001-bootstrap-2026.09.29.2"
|
||||
(target / "READY.json").write_text(json.dumps(ready), encoding="utf-8")
|
||||
conflict_hash = _sha256(agent)
|
||||
|
||||
report = _prepare(source, install_root, manifest)
|
||||
result = report["result"]
|
||||
assert result["initial_state"] == "TARGET_CONFLICT"
|
||||
quarantine = Path(str(result["quarantine_path"]))
|
||||
diagnostic_path = Path(str(result["quarantine_diagnostic"]))
|
||||
assert quarantine.is_dir()
|
||||
assert _sha256(quarantine / "app" / "tools" / agent.name) == conflict_hash
|
||||
diagnostic = json.loads(diagnostic_path.read_text(encoding="utf-8-sig"))
|
||||
assert diagnostic["bootstrap_id"] == BOOTSTRAP_ID
|
||||
assert diagnostic["expected_release_id"] == RELEASE_ID
|
||||
assert {row["kind"] for row in diagnostic["mismatches"]} >= {
|
||||
"SHA256_MISMATCH"
|
||||
}
|
||||
assert any(row["sha256"] == conflict_hash for row in diagnostic["conflicting_files"])
|
||||
_assert_matches_manifest(target, manifest)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected_kind"),
|
||||
(
|
||||
("ready_mismatch", "SHA256_MISMATCH"),
|
||||
("python_mismatch", "SHA256_MISMATCH"),
|
||||
("missing", "MISSING"),
|
||||
("unexpected", "UNEXPECTED"),
|
||||
),
|
||||
)
|
||||
def test_each_file_set_conflict_is_quarantined(
|
||||
tmp_path: Path,
|
||||
case: str,
|
||||
expected_kind: str,
|
||||
) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
if case == "ready_mismatch":
|
||||
(target / "READY.json").write_bytes(b"{}\n")
|
||||
elif case == "python_mismatch":
|
||||
(target / "app" / "tools" / "rf001_agent_update.py").write_bytes(b"changed\n")
|
||||
elif case == "missing":
|
||||
(target / "app" / "tools" / "rf001_agent_update.py").unlink()
|
||||
else:
|
||||
(target / "app" / "tools" / "unexpected.py").write_bytes(b"unexpected\n")
|
||||
|
||||
result = _prepare(source, install_root, manifest)["result"]
|
||||
diagnostic = json.loads(
|
||||
Path(str(result["quarantine_diagnostic"])).read_text(encoding="utf-8-sig")
|
||||
)
|
||||
assert expected_kind in {row["kind"] for row in diagnostic["mismatches"]}
|
||||
_assert_matches_manifest(target, manifest)
|
||||
|
||||
|
||||
def test_failure_after_quarantine_leaves_canonical_target_absent(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
(target / "READY.json").write_bytes(b"conflict\n")
|
||||
body = rf"""
|
||||
$state = Get-ReleaseDirectoryState `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix "{RELEASE_PREFIX}"
|
||||
$quarantine = Move-ConflictingTargetToQuarantine `
|
||||
-InstallRoot $installRoot `
|
||||
-VersionsRoot $versionsRoot `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseId "{RELEASE_ID}" `
|
||||
-BootstrapId "{BOOTSTRAP_ID}" `
|
||||
-State $state
|
||||
try {{
|
||||
throw "fixed failure after quarantine before target creation"
|
||||
}}
|
||||
catch {{
|
||||
[ordered]@{{
|
||||
error = $_.Exception.Message
|
||||
canonical_exists = Test-Path -LiteralPath $targetRelease
|
||||
quarantine_exists = Test-Path -LiteralPath $quarantine.path -PathType Container
|
||||
diagnostic_exists = Test-Path -LiteralPath $quarantine.diagnostic_path -PathType Leaf
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
completed = _run_powershell(
|
||||
body, source=source, install_root=install_root, manifest=manifest
|
||||
)
|
||||
report = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert report == {
|
||||
"error": "fixed failure after quarantine before target creation",
|
||||
"canonical_exists": False,
|
||||
"quarantine_exists": True,
|
||||
"diagnostic_exists": True,
|
||||
}
|
||||
|
||||
|
||||
def test_failure_during_deployment_quarantines_partial_staging(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
(source / "READY.json").unlink()
|
||||
body = rf"""
|
||||
try {{
|
||||
Install-CleanRelease `
|
||||
-InstallRoot $installRoot `
|
||||
-VersionsRoot $versionsRoot `
|
||||
-SourceRelease $sourceRelease `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseId "{RELEASE_ID}" `
|
||||
-BootstrapId "{BOOTSTRAP_ID}" `
|
||||
-AgentVersion "{AGENT_VERSION}" `
|
||||
-ProtocolVersion 1 `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix "{RELEASE_PREFIX}" | Out-Null
|
||||
throw "deployment unexpectedly succeeded"
|
||||
}}
|
||||
catch {{
|
||||
$failed = @(Get-ChildItem -LiteralPath (Join-Path $installRoot "quarantine") -Directory |
|
||||
Where-Object {{ $_.Name -like "{RELEASE_ID}_failed_staging_*" }})
|
||||
[ordered]@{{
|
||||
error = $_.Exception.Message
|
||||
canonical_exists = Test-Path -LiteralPath $targetRelease
|
||||
failed_staging_count = $failed.Count
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
completed = _run_powershell(
|
||||
body, source=source, install_root=install_root, manifest=manifest
|
||||
)
|
||||
report = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert "Clean release deployment failed" in report["error"]
|
||||
assert report["canonical_exists"] is False
|
||||
assert report["failed_staging_count"] == 1
|
||||
|
||||
|
||||
def test_installer_switch_failure_restores_saved_task_and_launcher_state() -> None:
|
||||
text = _installer().read_text(encoding="utf-8-sig")
|
||||
switch = text.index("Set-ScheduledTask -TaskName $taskName -Action $action")
|
||||
catch = text.index("catch {", switch)
|
||||
restore_task = text.index("Register-ScheduledTask -TaskName $taskName -Xml $xml -Force", catch)
|
||||
restore_files = text.index('foreach ($name in @("launcher"', catch)
|
||||
restart_old = text.index("if ($wasRunning)", catch)
|
||||
assert switch < catch < restore_task < restore_files < restart_old
|
||||
|
||||
|
||||
def test_successful_reinstall_is_idempotent(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
first = _prepare(source, install_root, manifest)
|
||||
before = _snapshot(target)
|
||||
second = _prepare(source, install_root, manifest)
|
||||
assert first["result"]["initial_state"] == "TARGET_ABSENT"
|
||||
assert second["result"]["initial_state"] == "TARGET_VERIFIED"
|
||||
assert second["result"]["reused"] is True
|
||||
assert _snapshot(target) == before
|
||||
quarantine = install_root / "quarantine"
|
||||
assert not quarantine.exists() or not any(quarantine.iterdir())
|
||||
|
||||
|
||||
def test_existing_quarantine_is_never_overwritten(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
(target / "READY.json").write_bytes(b"conflict\n")
|
||||
sentinel = install_root / "quarantine" / "existing-audit" / "sentinel.bin"
|
||||
sentinel.parent.mkdir(parents=True)
|
||||
sentinel.write_bytes(b"must remain unchanged")
|
||||
before = _snapshot(sentinel.parent)
|
||||
result = _prepare(source, install_root, manifest)["result"]
|
||||
assert _snapshot(sentinel.parent) == before
|
||||
assert Path(str(result["quarantine_path"])) != sentinel.parent
|
||||
|
||||
|
||||
def test_matching_version_strings_do_not_validate_changed_bytes(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
agent = target / "app" / "tools" / "rf001_rx_http_agent.py"
|
||||
agent.write_text(
|
||||
f'# changed bytes, same identity strings\nAGENT_VERSION = "{AGENT_VERSION}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _prepare(source, install_root, manifest)["result"]
|
||||
assert result["initial_state"] == "TARGET_CONFLICT"
|
||||
assert result["reused"] is False
|
||||
_assert_matches_manifest(target, manifest)
|
||||
235
tests/test_rf001_launcher_health_diagnostic.py
Normal file
235
tests/test_rf001_launcher_health_diagnostic.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Regression tests for strict launcher/health process identity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from tools.rf001_launcher_health_diagnostic import (
|
||||
HandshakeExpectation,
|
||||
ProcessRecord,
|
||||
_terminate_owned_processes,
|
||||
atomic_write_json,
|
||||
evaluate_identity,
|
||||
strict_owned_process_ids,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 29, 13, 0, tzinfo=timezone.utc)
|
||||
SCRIPT = r"C:\diagnostic\rf001_launcher_health_diagnostic.py"
|
||||
|
||||
|
||||
def _expectation(*, popen_pid: int = 200) -> HandshakeExpectation:
|
||||
return HandshakeExpectation(
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
launch_nonce="fixed-unique-nonce",
|
||||
state="RUNNING",
|
||||
launcher_pid=100,
|
||||
popen_pid=popen_pid,
|
||||
launched_at=NOW.isoformat(),
|
||||
diagnostic_script=SCRIPT,
|
||||
)
|
||||
|
||||
|
||||
def _record(pid: int, parent: int, *, seconds: float = 1.0) -> ProcessRecord:
|
||||
return ProcessRecord(
|
||||
pid=pid,
|
||||
parent_pid=parent,
|
||||
name="python.exe",
|
||||
executable=r"C:\Python313\python.exe",
|
||||
command_line=f'python "{SCRIPT}" --launch-nonce fixed-unique-nonce',
|
||||
created_at=(NOW + timedelta(seconds=seconds)).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _health(*, pid: int = 200) -> dict[str, object]:
|
||||
return {
|
||||
"release_id": "rf001-agent-2026.09.29.1",
|
||||
"agent_version": "2026.09.29.1",
|
||||
"launch_nonce": "fixed-unique-nonce",
|
||||
"state": "RUNNING",
|
||||
"pid": pid,
|
||||
"updated_at": (NOW + timedelta(seconds=2)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _evaluate(
|
||||
health: dict[str, object] | None,
|
||||
records: list[ProcessRecord],
|
||||
*,
|
||||
expectation: HandshakeExpectation | None = None,
|
||||
popen_alive: bool = True,
|
||||
health_process_alive: bool = True,
|
||||
) -> dict[str, object]:
|
||||
return evaluate_identity(
|
||||
health,
|
||||
expectation or _expectation(),
|
||||
records,
|
||||
popen_alive=popen_alive,
|
||||
health_process_alive=health_process_alive,
|
||||
)
|
||||
|
||||
|
||||
def test_exact_process_identity_passes() -> None:
|
||||
result = _evaluate(_health(), [_record(200, 100)])
|
||||
assert result["identity_valid"] is True
|
||||
assert result["identity_model"] == "EXACT_POPEN_PROCESS"
|
||||
|
||||
|
||||
def test_wrong_nonce_fails() -> None:
|
||||
health = _health()
|
||||
health["launch_nonce"] = "wrong"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_wrong_release_fails() -> None:
|
||||
health = _health()
|
||||
health["release_id"] = "rf001-agent-elsewhere"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_wrong_version_fails() -> None:
|
||||
health = _health()
|
||||
health["agent_version"] = "2026.09.29.2"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_health_before_running_state_fails() -> None:
|
||||
health = _health()
|
||||
health["state"] = "STARTING"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_stale_health_fails() -> None:
|
||||
health = _health()
|
||||
health["updated_at"] = (NOW - timedelta(seconds=1)).isoformat()
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "STALE_HEALTH"
|
||||
|
||||
|
||||
def test_rollback_does_not_accept_previous_launch_health() -> None:
|
||||
previous_health = _health()
|
||||
previous_health["launch_nonce"] = "previous-launch-nonce"
|
||||
assert (
|
||||
_evaluate(previous_health, [_record(200, 100)])["reason"]
|
||||
== "HEALTH_FIELDS_MISMATCH"
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_pid_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=300),
|
||||
[_record(200, 100), _record(300, 999)],
|
||||
)
|
||||
assert result["reason"] == "UNRELATED_PID"
|
||||
|
||||
|
||||
def test_unique_expected_wrapper_child_passes_strict_lineage() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=201),
|
||||
[_record(200, 100), _record(201, 200)],
|
||||
)
|
||||
assert result["identity_valid"] is True
|
||||
assert (
|
||||
result["identity_model"]
|
||||
== "UNIQUE_DIRECT_CHILD_WITH_NONCE_AND_CREATION_TIME"
|
||||
)
|
||||
assert result["pid_match"] is False
|
||||
|
||||
|
||||
def test_unexpected_grandchild_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=202),
|
||||
[_record(200, 100), _record(201, 200), _record(202, 201)],
|
||||
)
|
||||
assert result["reason"] == "UNEXPECTED_GRANDCHILD"
|
||||
|
||||
|
||||
def test_pid_reuse_creation_time_mismatch_fails() -> None:
|
||||
reused = _record(200, 100, seconds=-30)
|
||||
assert _evaluate(_health(), [reused])["reason"] == "PROCESS_CREATION_TIME_MISMATCH"
|
||||
|
||||
|
||||
def test_target_dies_before_health_fails() -> None:
|
||||
result = _evaluate(None, [_record(200, 100)], popen_alive=False)
|
||||
assert result["reason"] == "HEALTH_ABSENT"
|
||||
|
||||
|
||||
def test_target_writes_health_then_dies_immediately_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(),
|
||||
[_record(200, 100)],
|
||||
health_process_alive=False,
|
||||
)
|
||||
assert result["reason"] == "HEALTH_PROCESS_DIED"
|
||||
|
||||
|
||||
def test_orphan_selection_never_includes_unrelated_process() -> None:
|
||||
expectation = _expectation()
|
||||
related = _record(201, 200)
|
||||
unrelated = replace(_record(300, 999), command_line="python unrelated.py")
|
||||
assert strict_owned_process_ids(
|
||||
expectation,
|
||||
[_record(200, 100), related, unrelated],
|
||||
) == [201, 200]
|
||||
|
||||
|
||||
def test_orphan_cleanup_terminates_only_strictly_owned_processes(monkeypatch) -> None:
|
||||
expectation = _expectation()
|
||||
wrapper = _record(200, 100)
|
||||
child = _record(201, 200)
|
||||
unrelated = replace(_record(300, 999), command_line="python unrelated.py")
|
||||
records = [wrapper, child, unrelated]
|
||||
terminated: list[int] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.rf001_launcher_health_diagnostic.capture_windows_processes",
|
||||
lambda: records,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.rf001_launcher_health_diagnostic.os.kill",
|
||||
lambda pid, _signal: terminated.append(pid),
|
||||
)
|
||||
|
||||
actions = _terminate_owned_processes(expectation, records)
|
||||
|
||||
assert terminated == [201, 200]
|
||||
assert actions == [
|
||||
{"pid": 201, "status": "TERMINATED"},
|
||||
{"pid": 200, "status": "TERMINATED"},
|
||||
]
|
||||
|
||||
|
||||
def test_repeated_success_is_deterministic_with_distinct_identity() -> None:
|
||||
first = _expectation(popen_pid=200)
|
||||
second = replace(
|
||||
first,
|
||||
popen_pid=300,
|
||||
launch_nonce="second-unique-nonce",
|
||||
launched_at=(NOW + timedelta(seconds=10)).isoformat(),
|
||||
)
|
||||
first_result = _evaluate(_health(pid=200), [_record(200, 100)], expectation=first)
|
||||
second_record = replace(
|
||||
_record(300, 100, seconds=11),
|
||||
command_line=f'python "{SCRIPT}" --launch-nonce second-unique-nonce',
|
||||
)
|
||||
second_health = {
|
||||
**_health(pid=300),
|
||||
"launch_nonce": "second-unique-nonce",
|
||||
"updated_at": (NOW + timedelta(seconds=12)).isoformat(),
|
||||
}
|
||||
second_result = _evaluate(second_health, [second_record], expectation=second)
|
||||
assert first_result["identity_valid"] is True
|
||||
assert second_result["identity_valid"] is True
|
||||
assert first.launch_nonce != second.launch_nonce
|
||||
assert first.popen_pid != second.popen_pid
|
||||
|
||||
|
||||
def test_diagnostic_json_replacement_is_atomic(tmp_path: Path) -> None:
|
||||
path = tmp_path / "health.json"
|
||||
for sequence in range(20):
|
||||
atomic_write_json(path, {"sequence": sequence})
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"sequence": sequence}
|
||||
assert not path.with_suffix(".json.tmp").exists()
|
||||
55
tests/test_rf001_offline_decode.py
Normal file
55
tests/test_rf001_offline_decode.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Software-only checks for the fixed HTTP-agent offline decoder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf001_offline_decode import decode_offline
|
||||
|
||||
|
||||
def test_decode_offline_forces_legacy_and_refuses_overwrite(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
iq_path = tmp_path / "waveform.cs8"
|
||||
truth_path = tmp_path / "truth.json"
|
||||
report_path = tmp_path / "report.json"
|
||||
iq_path.write_bytes(b"iq")
|
||||
truth_path.write_text("{}", encoding="utf-8")
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def fake_decoder(iq_file: Path, **options: object) -> dict[str, object]:
|
||||
calls.append({"iq_file": iq_file, **options})
|
||||
return {"crc_valid_packets": 100, "exact_packets": 100}
|
||||
|
||||
result = decode_offline(
|
||||
repository,
|
||||
iq_path,
|
||||
truth_path,
|
||||
report_path,
|
||||
decoder=fake_decoder,
|
||||
)
|
||||
|
||||
assert result == {"crc_valid_packets": 100, "exact_packets": 100}
|
||||
assert calls == [
|
||||
{
|
||||
"iq_file": iq_path,
|
||||
"iq_format": "cs8",
|
||||
"truth_metadata_path": truth_path,
|
||||
"decoder_backend": "legacy",
|
||||
"capture_started_at": None,
|
||||
"tx_started_at": None,
|
||||
"tx_finished_at": None,
|
||||
}
|
||||
]
|
||||
assert json.loads(report_path.read_text(encoding="utf-8")) == result
|
||||
with pytest.raises(FileExistsError, match="already exists"):
|
||||
decode_offline(
|
||||
repository,
|
||||
iq_path,
|
||||
truth_path,
|
||||
report_path,
|
||||
decoder=fake_decoder,
|
||||
)
|
||||
356
tests/test_rf001_rx_capture.py
Normal file
356
tests/test_rf001_rx_capture.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""Software-only tests for the fixed RF-001A RX capture operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from tools.rf001_rx_capture import (
|
||||
CAPTURE_FILE_NAME,
|
||||
FIXED_RX_SERIAL,
|
||||
MANIFEST_FILE_NAME,
|
||||
REPORT_FILE_NAME,
|
||||
RESULT_FILE_NAME,
|
||||
read_capture_diagnostics,
|
||||
read_capture_status,
|
||||
start_rx_capture,
|
||||
)
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, arguments: Sequence[str], stdout: str) -> None:
|
||||
self.arguments = list(arguments)
|
||||
self.returncode = 0
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
|
||||
|
||||
def _write_repository(repository: Path) -> None:
|
||||
(repository / "configs" / "rf").mkdir(parents=True)
|
||||
(repository / "tools").mkdir()
|
||||
profile = {
|
||||
"test_id": "RF001A_COMMON_CLOCK_001",
|
||||
"center_frequency_hz": 1_400_000_000,
|
||||
"sample_rate_hz": 8_000_000,
|
||||
"symbol_rate_sps": 500_000,
|
||||
"modulation": "QPSK",
|
||||
"inner_fec": "2/3",
|
||||
"application_payload_bytes": 256,
|
||||
"packet_count": 100,
|
||||
"inter_packet_gap_ms": 5,
|
||||
"clock_mode": "COMMON_10_MHZ",
|
||||
"rx_lna_db": 24,
|
||||
"rx_vga_db": 24,
|
||||
"rf_amp_enabled": False,
|
||||
"antenna_bias_enabled": False,
|
||||
"hardware_format": "cs8",
|
||||
"rx_capture_seconds": 20,
|
||||
"rx_capture_samples": 160_000_000,
|
||||
}
|
||||
(repository / "configs" / "rf" / "rf001a_common_clock.json").write_text(
|
||||
json.dumps(profile),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_capture_start_uses_fixed_receive_only_command(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
_write_repository(repository)
|
||||
tool_dir = tmp_path / "hackrf"
|
||||
tool_dir.mkdir()
|
||||
info = tool_dir / "hackrf_info.exe"
|
||||
clock = tool_dir / "hackrf_clock.exe"
|
||||
transfer = tool_dir / "hackrf_transfer.exe"
|
||||
for path in (info, clock, transfer):
|
||||
path.write_bytes(b"")
|
||||
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(arguments: Sequence[str], timeout: float) -> FakeResult:
|
||||
del timeout
|
||||
command = list(arguments)
|
||||
commands.append(command)
|
||||
if command == [str(info)]:
|
||||
return FakeResult(command, f"Serial number: {FIXED_RX_SERIAL}")
|
||||
if command == [str(clock), "-d", FIXED_RX_SERIAL, "-i"]:
|
||||
return FakeResult(command, "CLKIN status: clock signal detected")
|
||||
raise AssertionError(command)
|
||||
|
||||
launched: list[list[str]] = []
|
||||
capture_path: Path | None = None
|
||||
|
||||
def launcher(arguments: Sequence[str]) -> int:
|
||||
nonlocal capture_path
|
||||
command = list(arguments)
|
||||
launched.append(command)
|
||||
capture_path = Path(command[command.index("--capture-path") + 1])
|
||||
started_path = Path(command[command.index("--started-status") + 1])
|
||||
started_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": "RUNNING",
|
||||
"pid": 4321,
|
||||
"started_at": "2026-09-23T15:00:00+00:00",
|
||||
"capture_path": str(capture_path),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
capture_path.write_bytes(b"12")
|
||||
return 1234
|
||||
|
||||
def grow(_: float) -> None:
|
||||
assert capture_path is not None
|
||||
with capture_path.open("ab") as stream:
|
||||
stream.write(b"34")
|
||||
|
||||
result, errors = start_rx_capture(
|
||||
repository,
|
||||
tmp_path / "runtime",
|
||||
"RF001A_20260923T150000Z_1234ABCD",
|
||||
FIXED_RX_SERIAL,
|
||||
hackrf_info_path=info,
|
||||
hackrf_clock_path=clock,
|
||||
hackrf_transfer_path=transfer,
|
||||
command_runner=runner,
|
||||
process_gate=lambda: (True, {"state": "CLEAR"}),
|
||||
background_launcher=launcher,
|
||||
process_stopper=lambda pid: None,
|
||||
sleep=grow,
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert result["state"] == "READY"
|
||||
assert result["rf_rx"] == 1
|
||||
assert result["rf_tx"] == 0
|
||||
assert result["automatic_retry"] is False
|
||||
assert result["common_clock_verified"] is True
|
||||
assert len(launched) == 1
|
||||
arguments_path = Path(
|
||||
launched[0][launched[0].index("--arguments-json") + 1]
|
||||
)
|
||||
transfer_arguments = json.loads(arguments_path.read_text(encoding="utf-8"))
|
||||
assert transfer_arguments == [
|
||||
"-d", FIXED_RX_SERIAL,
|
||||
"-r", str(result["capture_path"]),
|
||||
"-f", "1400000000",
|
||||
"-s", "8000000",
|
||||
"-n", "2400000000",
|
||||
"-l", "24",
|
||||
"-g", "24",
|
||||
"-a", "0",
|
||||
"-p", "0",
|
||||
"-B",
|
||||
]
|
||||
assert "-t" not in transfer_arguments
|
||||
assert commands == [
|
||||
[str(info)],
|
||||
[str(clock), "-d", FIXED_RX_SERIAL, "-i"],
|
||||
]
|
||||
|
||||
|
||||
def test_capture_start_rejects_explicit_no_clock_status(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
_write_repository(repository)
|
||||
tool_dir = tmp_path / "hackrf"
|
||||
tool_dir.mkdir()
|
||||
info = tool_dir / "hackrf_info.exe"
|
||||
clock = tool_dir / "hackrf_clock.exe"
|
||||
transfer = tool_dir / "hackrf_transfer.exe"
|
||||
for path in (info, clock, transfer):
|
||||
path.write_bytes(b"")
|
||||
|
||||
def runner(arguments: Sequence[str], timeout: float) -> FakeResult:
|
||||
del timeout
|
||||
command = list(arguments)
|
||||
if command == [str(info)]:
|
||||
return FakeResult(command, f"Serial number: {FIXED_RX_SERIAL}")
|
||||
if command == [str(clock), "-d", FIXED_RX_SERIAL, "-i"]:
|
||||
return FakeResult(command, "CLKIN status: no clock signal detected")
|
||||
raise AssertionError(command)
|
||||
|
||||
launched: list[list[str]] = []
|
||||
result, errors = start_rx_capture(
|
||||
repository,
|
||||
tmp_path / "runtime",
|
||||
"RF001A_20260925T120000Z_1234ABCD",
|
||||
FIXED_RX_SERIAL,
|
||||
hackrf_info_path=info,
|
||||
hackrf_clock_path=clock,
|
||||
hackrf_transfer_path=transfer,
|
||||
command_runner=runner,
|
||||
process_gate=lambda: (True, {"state": "CLEAR"}),
|
||||
background_launcher=lambda arguments: launched.append(list(arguments)) or 1234,
|
||||
process_stopper=lambda pid: None,
|
||||
)
|
||||
|
||||
assert result["hardware_accessed"] is True
|
||||
assert result["rf_rx"] == 0
|
||||
assert result["rf_tx"] == 0
|
||||
assert result["common_clock_verified"] is False
|
||||
assert errors == ["RuntimeError: CLKIN clock signal was not detected"]
|
||||
assert launched == []
|
||||
|
||||
|
||||
def test_capture_status_requires_exact_complete_file(tmp_path: Path) -> None:
|
||||
session_id = "RF001A_20260923T150000Z_1234ABCD"
|
||||
session = tmp_path / "rx_capture" / session_id
|
||||
session.mkdir(parents=True)
|
||||
capture = session / CAPTURE_FILE_NAME
|
||||
capture.write_bytes(b"1234")
|
||||
result_path = session / RESULT_FILE_NAME
|
||||
result_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"exit_code": 0,
|
||||
"runner_error": None,
|
||||
"capture_size": 4,
|
||||
"sha256": "fixture",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(session / MANIFEST_FILE_NAME).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"capture_path": str(capture),
|
||||
"capture_started_at": "2026-09-23T15:00:00+00:00",
|
||||
"expected_capture_end": "2026-09-23T15:00:20+00:00",
|
||||
"expected_capture_bytes": 4,
|
||||
"result_status_path": str(result_path),
|
||||
"common_clock_verified": True,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result, errors = read_capture_status(tmp_path, session_id)
|
||||
|
||||
assert errors == []
|
||||
assert result["state"] == "CAPTURE_COMPLETE"
|
||||
assert result["capture_bytes"] == 4
|
||||
assert result["hardware_accessed"] is False
|
||||
assert result["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_capture_diagnostics_returns_report_and_signal_power(tmp_path: Path) -> None:
|
||||
session_id = "RF001A_20260925T104504Z_5FA2C83B"
|
||||
session = tmp_path / "rx_capture" / session_id
|
||||
session.mkdir(parents=True)
|
||||
capture = session / CAPTURE_FILE_NAME
|
||||
samples = bytearray()
|
||||
for index in range(200):
|
||||
code = 10 if 80 <= index < 120 else 1
|
||||
samples.extend((code, code))
|
||||
capture.write_bytes(samples)
|
||||
capture_sha256 = hashlib.sha256(samples).hexdigest()
|
||||
result_path = session / RESULT_FILE_NAME
|
||||
result_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"exit_code": 0,
|
||||
"runner_error": None,
|
||||
"capture_size": len(samples),
|
||||
"sha256": capture_sha256,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(session / MANIFEST_FILE_NAME).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"capture_path": str(capture),
|
||||
"capture_started_at": "2026-09-25T10:00:00+00:00",
|
||||
"expected_capture_end": "2026-09-25T10:00:01+00:00",
|
||||
"expected_capture_bytes": len(samples),
|
||||
"result_status_path": str(result_path),
|
||||
"common_clock_verified": True,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
report = {
|
||||
"rf_tx": 0,
|
||||
"iq_sha256": capture_sha256,
|
||||
"detected_preambles": 0,
|
||||
"rejected_candidates": 0,
|
||||
"crc_valid_packets": 0,
|
||||
"exact_packets": 0,
|
||||
"rx_clipping_rail_codes": 0,
|
||||
"analysis_windows": [{"start_sample": 64, "stop_sample": 136}],
|
||||
}
|
||||
(session / REPORT_FILE_NAME).write_text(
|
||||
json.dumps(report),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
diagnostics, errors = read_capture_diagnostics(
|
||||
tmp_path,
|
||||
session_id,
|
||||
"2026-09-25T10:00:00.000010+00:00",
|
||||
"2026-09-25T10:00:00.000015+00:00",
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert diagnostics["hardware_accessed"] is False
|
||||
assert diagnostics["rf_tx"] == 0
|
||||
assert diagnostics["report"] == report
|
||||
signal = diagnostics["signal_metrics"]
|
||||
assert signal["tx_window"]["start_sample"] == 80
|
||||
assert signal["tx_window"]["stop_sample"] == 120
|
||||
assert signal["tx_to_baseline_power_ratio"] == 100.0
|
||||
assert signal["tx_to_baseline_db"] == 20.0
|
||||
|
||||
|
||||
def test_missing_process_gate_dependency_reports_failure_without_hardware(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
_write_repository(repository)
|
||||
tool_dir = tmp_path / "hackrf"
|
||||
tool_dir.mkdir()
|
||||
info = tool_dir / "hackrf_info.exe"
|
||||
clock = tool_dir / "hackrf_clock.exe"
|
||||
transfer = tool_dir / "hackrf_transfer.exe"
|
||||
for path in (info, clock, transfer):
|
||||
path.write_bytes(b"")
|
||||
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(arguments: Sequence[str], timeout: float) -> FakeResult:
|
||||
del timeout
|
||||
commands.append(list(arguments))
|
||||
return FakeResult(arguments, "unexpected")
|
||||
|
||||
def missing_gate() -> tuple[bool, dict[str, object]]:
|
||||
raise ModuleNotFoundError("tools.hackrf_process_check")
|
||||
|
||||
result, errors = start_rx_capture(
|
||||
repository,
|
||||
tmp_path / "runtime",
|
||||
"RF001A_20260924T090807Z_B8E6794D",
|
||||
FIXED_RX_SERIAL,
|
||||
hackrf_info_path=info,
|
||||
hackrf_clock_path=clock,
|
||||
hackrf_transfer_path=transfer,
|
||||
command_runner=runner,
|
||||
process_gate=missing_gate,
|
||||
process_stopper=lambda pid: None,
|
||||
)
|
||||
|
||||
assert result["hardware_accessed"] is False
|
||||
assert result["rf_rx"] == 0
|
||||
assert result["rf_tx"] == 0
|
||||
assert errors == [
|
||||
"ModuleNotFoundError: tools.hackrf_process_check"
|
||||
]
|
||||
assert commands == []
|
||||
824
tests/test_rf001_rx_http_agent.py
Normal file
824
tests/test_rf001_rx_http_agent.py
Normal file
@@ -0,0 +1,824 @@
|
||||
"""Software-only tests for the fixed-command RX HTTP agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_agent_update as update_module
|
||||
import tools.rf001_rx_http_agent as agent_module
|
||||
from tools.rf001_http_control import PROTOCOL_VERSION, ProtocolError
|
||||
from tools.rf001_rx_http_agent import (
|
||||
CommandResult,
|
||||
collect_inventory,
|
||||
execute_task,
|
||||
normalized_agent_id,
|
||||
read_git_metadata,
|
||||
)
|
||||
|
||||
|
||||
def fake_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
del timeout
|
||||
command = list(arguments)
|
||||
if command == ["hostname"]:
|
||||
stdout = "User-PC"
|
||||
elif command == ["whoami"]:
|
||||
stdout = "user-pc\\professional"
|
||||
elif command[0] == "git":
|
||||
stdout = "fake-git-read-only-result"
|
||||
elif command[0].lower().endswith("hackrf_info.exe"):
|
||||
stdout = "hackrf_info version: 2024.02.1"
|
||||
else: # pragma: no cover - protects the test allowlist
|
||||
raise AssertionError(f"unexpected command: {command}")
|
||||
return CommandResult(command, 0, stdout, "")
|
||||
|
||||
|
||||
def test_agent_id_is_normalized_without_shell_content() -> None:
|
||||
assert normalized_agent_id("User-PC") == "user-pc"
|
||||
assert normalized_agent_id(" RX Laptop! ") == "rx-laptop"
|
||||
|
||||
|
||||
def test_inventory_runs_only_fixed_read_only_commands(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
hackrf_info = tmp_path / "hackrf_info.exe"
|
||||
hackrf_info.write_bytes(b"")
|
||||
|
||||
inventory, errors = collect_inventory(
|
||||
repository,
|
||||
include_hackrf_info=True,
|
||||
hackrf_info_path=hackrf_info,
|
||||
command_runner=fake_runner,
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert inventory["rf_tx"] == 0
|
||||
assert inventory["hostname_command"]["stdout"] == "User-PC"
|
||||
assert inventory["whoami_command"]["stdout"] == "user-pc\\professional"
|
||||
assert inventory["hackrf_info"]["executed"] is True
|
||||
assert inventory["hackrf_info"]["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_agent_rejects_unknown_task_and_rf_tx(tmp_path: Path) -> None:
|
||||
base_task: dict[str, object] = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "INVENTORY_TEST_001",
|
||||
"kind": "INVENTORY",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": "2026-09-23T00:00:00+00:00",
|
||||
"include_hackrf_info": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
unknown = dict(base_task)
|
||||
unknown["kind"] = "SHELL"
|
||||
with pytest.raises(ProtocolError, match="unsupported task kind"):
|
||||
execute_task(
|
||||
unknown,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
command_runner=fake_runner,
|
||||
)
|
||||
|
||||
transmit = dict(base_task)
|
||||
transmit["rf_tx"] = 1
|
||||
with pytest.raises(ProtocolError, match="rf_tx = 0"):
|
||||
execute_task(
|
||||
transmit,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
command_runner=fake_runner,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_dispatches_fixed_capture_diagnostics_without_hardware(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[Path, str, str, str]] = []
|
||||
|
||||
def diagnostics(
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
calls.append((work_root, session_id, tx_started_at, tx_finished_at))
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
"report": {"detected_preambles": 0},
|
||||
}, []
|
||||
|
||||
monkeypatch.setattr(agent_module, "read_capture_diagnostics", diagnostics)
|
||||
session_id = "RF001A_20260925T104504Z_5FA2C83B"
|
||||
task = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "DIAG_TEST_001",
|
||||
"kind": "CAPTURE_DIAGNOSTICS",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": "2026-09-25T10:51:00+00:00",
|
||||
"session_id": session_id,
|
||||
"tx_started_at": "2026-09-25T10:46:54+00:00",
|
||||
"tx_finished_at": "2026-09-25T10:46:56+00:00",
|
||||
"rf_tx": 0,
|
||||
}
|
||||
|
||||
result = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=tmp_path / "runtime",
|
||||
command_runner=fake_runner,
|
||||
)
|
||||
|
||||
assert result["status"] == "COMPLETE"
|
||||
assert result["result"]["hardware_accessed"] is False
|
||||
assert result["rf_tx"] == 0
|
||||
assert calls == [
|
||||
(
|
||||
tmp_path / "runtime",
|
||||
session_id,
|
||||
"2026-09-25T10:46:54+00:00",
|
||||
"2026-09-25T10:46:56+00:00",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _write_git_metadata(repository: Path, head: str) -> None:
|
||||
git_dir = repository / ".git"
|
||||
ref = git_dir / "refs" / "heads" / "video-development"
|
||||
ref.parent.mkdir(parents=True)
|
||||
(git_dir / "HEAD").write_text(
|
||||
"ref: refs/heads/video-development\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
ref.write_text(head + "\n", encoding="ascii")
|
||||
|
||||
|
||||
def test_read_git_metadata_without_git_executable(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
head = "a94f2db4ee8377201a97263166cb09431df5f5d6"
|
||||
_write_git_metadata(repository, head)
|
||||
|
||||
metadata = read_git_metadata(repository)
|
||||
|
||||
assert metadata["branch"] == "video-development"
|
||||
assert metadata["head"] == head
|
||||
assert metadata["status"] == "NOT_RUN_NO_GIT_EXECUTABLE"
|
||||
|
||||
|
||||
def test_read_git_metadata_from_linked_worktree(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "worktree"
|
||||
repository.mkdir()
|
||||
common = tmp_path / "main.git"
|
||||
worktree_git = common / "worktrees" / "worktree"
|
||||
worktree_git.mkdir(parents=True)
|
||||
(repository / ".git").write_text(
|
||||
f"gitdir: {worktree_git}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(worktree_git / "commondir").write_text("../..\n", encoding="ascii")
|
||||
(worktree_git / "HEAD").write_text(
|
||||
"ref: refs/heads/video-development\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
head = "a94f2db4ee8377201a97263166cb09431df5f5d6"
|
||||
ref = common / "refs" / "heads" / "video-development"
|
||||
ref.parent.mkdir(parents=True)
|
||||
ref.write_text(head + "\n", encoding="ascii")
|
||||
|
||||
metadata = read_git_metadata(repository)
|
||||
|
||||
assert metadata["git_dir"] == str(worktree_git.resolve())
|
||||
assert metadata["common_dir"] == str(common.resolve())
|
||||
assert metadata["branch"] == "video-development"
|
||||
assert metadata["head"] == head
|
||||
|
||||
|
||||
def test_offline_verify_is_fixed_software_only_task(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
head = "a94f2db4ee8377201a97263166cb09431df5f5d6"
|
||||
_write_git_metadata(repository, head)
|
||||
(repository / "tools").mkdir()
|
||||
(repository / "configs" / "rf").mkdir(parents=True)
|
||||
for relative in (
|
||||
Path("tools/rf001a_prepare.py"),
|
||||
Path("tools/qpsk_rf_rx.py"),
|
||||
Path("configs/rf/rf001a_truth_metadata.json"),
|
||||
):
|
||||
(repository / relative).write_text("fixture\n", encoding="utf-8")
|
||||
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def offline_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
del timeout
|
||||
command = list(arguments)
|
||||
commands.append(command)
|
||||
if "rf001a_prepare.py" in command[4]:
|
||||
output = Path(command[command.index("--output-dir") + 1])
|
||||
output.mkdir()
|
||||
(output / "tx_waveform.cs8").write_bytes(b"fixture")
|
||||
(output / "tx_metadata.json").write_text("{}", encoding="utf-8")
|
||||
elif "rf001_offline_decode.py" in command[4]:
|
||||
report = Path(command[command.index("--report") + 1])
|
||||
report.write_text(
|
||||
'{"crc_valid_packets": 100, "exact_packets": 100}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
else: # pragma: no cover - protects the fixed command contract
|
||||
raise AssertionError(command)
|
||||
return CommandResult(command, 0, "PASS", "")
|
||||
|
||||
task = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "OFFLINE_TEST_001",
|
||||
"kind": "OFFLINE_VERIFY",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": "2026-09-23T00:00:00+00:00",
|
||||
"expected_head": head,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
result = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=repository,
|
||||
hackrf_info_path=None,
|
||||
work_root=tmp_path / "runtime",
|
||||
command_runner=offline_runner,
|
||||
)
|
||||
|
||||
assert result["status"] == "COMPLETE"
|
||||
assert result["result"]["hardware_accessed"] is False
|
||||
assert result["result"]["crc_valid_packets"] == 100
|
||||
assert result["result"]["exact_packets"] == 100
|
||||
assert "--repository" in commands[1]
|
||||
assert all("hackrf" not in Path(command[0]).name.lower() for command in commands)
|
||||
assert all("-t" not in command for command in commands)
|
||||
assert result["rf_tx"] == 0
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _staircase_fixture(
|
||||
tmp_path: Path,
|
||||
) -> tuple[Path, dict[str, object], Path, Path]:
|
||||
work_root = tmp_path / "runtime"
|
||||
session_id = "RF001A_20260925T125829Z_227EADB4"
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
session_directory.mkdir(parents=True)
|
||||
capture = session_directory / "rf001a_ota_capture.cs8"
|
||||
capture.write_bytes(b"fixed-rx-capture")
|
||||
reference = work_root / "references" / "lab065_reference_staircase.cs8"
|
||||
reference.parent.mkdir(parents=True)
|
||||
reference.write_bytes(b"fixed-staircase-reference")
|
||||
task: dict[str, object] = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "STAIRCASE_TEST_001",
|
||||
"kind": "STAIRCASE_SEARCH",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": "2026-09-28T12:00:00+00:00",
|
||||
"session_id": session_id,
|
||||
"expected_rx_sha256": _sha256(capture),
|
||||
"expected_reference_sha256": _sha256(reference),
|
||||
"hardware_accessed": False,
|
||||
"automatic_retry": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
return work_root, task, capture, reference
|
||||
|
||||
|
||||
def _write_staircase_report(arguments: Sequence[str]) -> Path:
|
||||
command = list(arguments)
|
||||
report_path = Path(command[command.index("--report") + 1])
|
||||
report = {
|
||||
"analyzer_version": "test",
|
||||
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
|
||||
"capture_sha256": command[command.index("--capture-sha256") + 1],
|
||||
"reference_sha256": _sha256(
|
||||
Path(command[command.index("--reference") + 1])
|
||||
),
|
||||
"search": {
|
||||
"coverage_start_sample": 0,
|
||||
"coverage_stop_sample": 8,
|
||||
"coverage_seconds": 0.000001,
|
||||
"coverage_complete": True,
|
||||
"source_samples_processed": 8,
|
||||
"frequency_grid_hz": [-3.0, 0.0, 3.0],
|
||||
"background_score_p99": 0.01,
|
||||
"candidates": [],
|
||||
},
|
||||
"verifications": [],
|
||||
"verdict": "NOT_DETECTED",
|
||||
"verdict_reasons": ["NO_CANDIDATES"],
|
||||
"analysis_seconds": 1.25,
|
||||
"peak_process_memory_bytes": 1234,
|
||||
"peak_additional_memory_bytes": 456,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
report_path.write_text(json.dumps(report), encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
|
||||
def test_staircase_task_rejects_invalid_session_and_hashes(tmp_path: Path) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
invalid_session = dict(task, session_id="..\\outside")
|
||||
with pytest.raises(ProtocolError, match="session_id"):
|
||||
execute_task(
|
||||
invalid_session,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
)
|
||||
|
||||
for field in ("expected_rx_sha256", "expected_reference_sha256"):
|
||||
invalid_hash = dict(task)
|
||||
invalid_hash[field] = "invalid"
|
||||
with pytest.raises(ProtocolError, match=field):
|
||||
execute_task(
|
||||
invalid_hash,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
)
|
||||
|
||||
|
||||
def test_staircase_hash_mismatch_does_not_start_child(tmp_path: Path) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def forbidden_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
del timeout
|
||||
calls.append(list(arguments))
|
||||
raise AssertionError("child process must not run after a hash mismatch")
|
||||
|
||||
for field in ("expected_rx_sha256", "expected_reference_sha256"):
|
||||
changed = dict(task)
|
||||
changed[field] = "0" * 64
|
||||
result = execute_task(
|
||||
changed,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
command_runner=forbidden_runner,
|
||||
)
|
||||
assert result["status"] == "FAILED"
|
||||
assert result["result"]["reference_detection"] == "EXECUTION_FAILED"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_staircase_repeat_reuses_existing_report_without_child(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def analyzer_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
assert timeout == 43_200.0
|
||||
calls.append(list(arguments))
|
||||
_write_staircase_report(arguments)
|
||||
assert "hackrf" not in " ".join(arguments).lower()
|
||||
return CommandResult(list(arguments), 0, "REFERENCE STAIRCASE = NOT_DETECTED", "")
|
||||
|
||||
first = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
command_runner=analyzer_runner,
|
||||
)
|
||||
second = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
command_runner=lambda *_args: (_ for _ in ()).throw(
|
||||
AssertionError("existing report must not start another child")
|
||||
),
|
||||
)
|
||||
assert first["status"] == "COMPLETE"
|
||||
assert first["result"]["hardware_accessed"] is False
|
||||
assert first["result"]["rf_tx"] == 0
|
||||
assert first["result"]["reused_existing_report"] is False
|
||||
assert second["status"] == "COMPLETE"
|
||||
assert second["result"]["reused_existing_report"] is True
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_staircase_child_failure_is_execution_failed(tmp_path: Path) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
|
||||
def failed_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
del timeout
|
||||
return CommandResult(list(arguments), 9, "", "fixed failure")
|
||||
|
||||
result = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
command_runner=failed_runner,
|
||||
)
|
||||
assert result["status"] == "FAILED"
|
||||
assert result["result"]["reference_detection"] == "EXECUTION_FAILED"
|
||||
assert result["result"]["hardware_accessed"] is False
|
||||
assert result["result"]["rf_tx"] == 0
|
||||
assert "exited with 9" in result["errors"][0]
|
||||
|
||||
|
||||
def _update_fixture(
|
||||
tmp_path: Path,
|
||||
) -> tuple[
|
||||
Path,
|
||||
bytes,
|
||||
dict[str, object],
|
||||
dict[str, object],
|
||||
argparse.Namespace,
|
||||
]:
|
||||
source = tmp_path / "source"
|
||||
for index, relative in enumerate(update_module.RELEASE_SOURCE_FILES.values()):
|
||||
path = source / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# fixed update fixture {index}\n"
|
||||
if relative == update_module.RELEASE_SOURCE_FILES[
|
||||
"app/tools/rf001_rx_http_agent.py"
|
||||
]:
|
||||
content += 'AGENT_VERSION = "2026.09.29.2"\n'
|
||||
path.write_text(content, encoding="utf-8")
|
||||
release_id = "rf001-agent-2026.09.29.2"
|
||||
package = tmp_path / f"{release_id}.zip"
|
||||
inspection = update_module.build_release_archive(
|
||||
source,
|
||||
package,
|
||||
release_id=release_id,
|
||||
agent_version="2026.09.29.2",
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
)
|
||||
manifest: dict[str, object] = {
|
||||
**inspection.descriptor(),
|
||||
"package_kind": "AGENT_RELEASE",
|
||||
}
|
||||
token = b"fixed-test-token-0123456789abcdef"
|
||||
task: dict[str, object] = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "UPDATE_TEST_001",
|
||||
"kind": "UPDATE_AGENT",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"release_id": release_id,
|
||||
"agent_version": "2026.09.29.2",
|
||||
"package_sha256": inspection.package_sha256,
|
||||
"package_bytes": inspection.package_bytes,
|
||||
"content_manifest_sha256": inspection.content_manifest_sha256,
|
||||
"compatible_protocol_version": PROTOCOL_VERSION,
|
||||
"approval_expires_at": (
|
||||
datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
).isoformat(),
|
||||
"automatic_retry": False,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
install_root = tmp_path / "install"
|
||||
install_root.mkdir()
|
||||
launcher_status = install_root / "launcher_status.json"
|
||||
launcher_status.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"active_release_id": "rf001-agent-2026.09.29.1",
|
||||
"active_agent_version": "2026.09.29.1",
|
||||
"rf_tx": 0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
arguments = argparse.Namespace(
|
||||
agent_id="rx-user-pc",
|
||||
server="http://127.0.0.1:8765",
|
||||
timeout=1.0,
|
||||
install_root=install_root,
|
||||
launcher_request=install_root / "launcher_request.json",
|
||||
launcher_status=launcher_status,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
)
|
||||
return package, token, task, manifest, arguments
|
||||
|
||||
|
||||
def _successful_self_test(
|
||||
arguments: Sequence[str], timeout: float
|
||||
) -> CommandResult:
|
||||
assert timeout == 120.0
|
||||
assert "hackrf" not in " ".join(arguments).lower()
|
||||
return CommandResult(list(arguments), 0, "SELF TEST = PASS", "")
|
||||
|
||||
|
||||
def test_agent_stages_authenticated_update_and_reuses_it(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
package, token, task, manifest, arguments = _update_fixture(tmp_path)
|
||||
downloads: list[Path] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": update_module.sign_update_manifest(token, manifest),
|
||||
},
|
||||
)
|
||||
|
||||
def download(
|
||||
_server: str,
|
||||
_token: bytes,
|
||||
_target: str,
|
||||
destination: Path,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
downloads.append(destination)
|
||||
shutil.copyfile(package, destination)
|
||||
|
||||
monkeypatch.setattr(agent_module, "request_file", download)
|
||||
result, errors, request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert errors == []
|
||||
assert result["update_status"] == "STAGED"
|
||||
assert result["hardware_accessed"] is False
|
||||
assert result["rf_tx"] == 0
|
||||
assert request is not None
|
||||
assert len(downloads) == 1
|
||||
|
||||
result, errors, repeated_request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=lambda *_args: (_ for _ in ()).throw(
|
||||
AssertionError("an already staged release must not run again")
|
||||
),
|
||||
)
|
||||
assert errors == []
|
||||
assert result["update_status"] == "ALREADY_STAGED"
|
||||
assert repeated_request is not None
|
||||
assert {
|
||||
key: value
|
||||
for key, value in repeated_request.items()
|
||||
if key != "requested_at"
|
||||
} == {key: value for key, value in request.items() if key != "requested_at"}
|
||||
assert len(downloads) == 1
|
||||
|
||||
|
||||
def test_agent_rejects_bad_update_signature_sha_and_protocol(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_package, token, task, manifest, arguments = _update_fixture(tmp_path)
|
||||
network_downloads: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": "0" * 64,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_file",
|
||||
lambda *_args, **_kwargs: network_downloads.append("called"),
|
||||
)
|
||||
result, errors, request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert result["update_status"] == "EXECUTION_FAILED"
|
||||
assert any("signature mismatch" in error for error in errors)
|
||||
assert request is None
|
||||
assert network_downloads == []
|
||||
|
||||
incompatible = dict(task, compatible_protocol_version=PROTOCOL_VERSION + 1)
|
||||
_result, errors, _request = agent_module.stage_agent_update(
|
||||
incompatible,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert any("incompatible" in error for error in errors)
|
||||
|
||||
changed_sha = dict(task, package_sha256="0" * 64)
|
||||
signed = update_module.sign_update_manifest(token, manifest)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": signed,
|
||||
},
|
||||
)
|
||||
_result, errors, _request = agent_module.stage_agent_update(
|
||||
changed_sha,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert any("field mismatch: package_sha256" in error for error in errors)
|
||||
assert network_downloads == []
|
||||
|
||||
|
||||
def test_agent_retries_one_interrupted_download_then_stages(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
package, token, task, manifest, arguments = _update_fixture(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": update_module.sign_update_manifest(token, manifest),
|
||||
},
|
||||
)
|
||||
calls = 0
|
||||
|
||||
def interrupted_then_complete(
|
||||
_server: str,
|
||||
_token: bytes,
|
||||
_target: str,
|
||||
destination: Path,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
destination.write_bytes(b"partial")
|
||||
raise OSError("fixed interrupted transfer")
|
||||
shutil.copyfile(package, destination)
|
||||
|
||||
monkeypatch.setattr(agent_module, "request_file", interrupted_then_complete)
|
||||
result, errors, request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert errors == []
|
||||
assert result["update_status"] == "STAGED"
|
||||
assert request is not None
|
||||
assert calls == 2
|
||||
state = json.loads(
|
||||
(
|
||||
arguments.install_root
|
||||
/ "staging"
|
||||
/ str(task["task_id"])
|
||||
/ "download_state.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
assert state["network_attempts"] == 2
|
||||
|
||||
|
||||
def test_agent_does_not_exceed_persisted_download_attempt_limit(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_package, token, task, manifest, arguments = _update_fixture(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": update_module.sign_update_manifest(token, manifest),
|
||||
},
|
||||
)
|
||||
calls = 0
|
||||
|
||||
def always_interrupted(*_args: object, **_kwargs: object) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise OSError("fixed interrupted transfer")
|
||||
|
||||
monkeypatch.setattr(agent_module, "request_file", always_interrupted)
|
||||
for _ in range(2):
|
||||
result, errors, request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert result["update_status"] == "EXECUTION_FAILED"
|
||||
assert errors
|
||||
assert request is None
|
||||
assert calls == 2
|
||||
|
||||
|
||||
def test_agent_restart_does_not_duplicate_background_analyzer(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
starts: list[list[str]] = []
|
||||
|
||||
class FakeProcess:
|
||||
pid = 4242
|
||||
|
||||
def fake_popen(arguments: Sequence[str], **_kwargs: object) -> FakeProcess:
|
||||
starts.append(list(arguments))
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(agent_module.subprocess, "Popen", fake_popen)
|
||||
pointer = agent_module.start_staircase_background_job(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
work_root=work_root,
|
||||
)
|
||||
assert pointer["pid"] == 4242
|
||||
with pytest.raises(RuntimeError, match="already active"):
|
||||
agent_module.start_staircase_background_job(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
work_root=work_root,
|
||||
)
|
||||
assert len(starts) == 1
|
||||
assert pointer["hardware_accessed"] is False
|
||||
assert pointer["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_completed_background_result_survives_network_loss(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
directory = work_root / "agent_jobs" / str(task["task_id"])
|
||||
directory.mkdir(parents=True)
|
||||
pointer = {
|
||||
"schema_version": 1,
|
||||
"task": task,
|
||||
"pid": 999999,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"automatic_retry": False,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
agent_module.atomic_write_json(
|
||||
work_root / "agent_jobs" / "active_job.json", pointer
|
||||
)
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "COMPLETE",
|
||||
"result": {"hardware_accessed": False, "rf_tx": 0},
|
||||
"errors": [],
|
||||
"rf_tx": 0,
|
||||
}
|
||||
agent_module.atomic_write_json(directory / "result.json", result)
|
||||
arguments = argparse.Namespace(
|
||||
work_root=work_root,
|
||||
server="http://127.0.0.1:8765",
|
||||
timeout=1.0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
OSError("fixed network loss")
|
||||
),
|
||||
)
|
||||
with pytest.raises(OSError, match="network loss"):
|
||||
agent_module.deliver_background_result(arguments, b"token", pointer)
|
||||
assert (work_root / "agent_jobs" / "active_job.json").is_file()
|
||||
assert (directory / "result.json").is_file()
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {"accepted": True, "rf_tx": 0},
|
||||
)
|
||||
assert agent_module.deliver_background_result(arguments, b"token", pointer)
|
||||
assert not (work_root / "agent_jobs" / "active_job.json").exists()
|
||||
assert (directory / "delivered_job.json").is_file()
|
||||
assert (directory / "result.json").is_file()
|
||||
354
tests/test_rf001_staircase_search.py
Normal file
354
tests/test_rf001_staircase_search.py
Normal file
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy import signal
|
||||
|
||||
from tools.rf001_staircase_search import (
|
||||
DEFAULT_CRITERIA,
|
||||
DEFAULT_LAYOUT,
|
||||
EXPECTED_REFERENCE_SHA256,
|
||||
ReferenceLayout,
|
||||
SearchCriteria,
|
||||
analyze,
|
||||
design_antialias_filter,
|
||||
locate_reference_streaming,
|
||||
load_reference,
|
||||
read_filtered_decimated_window,
|
||||
verify_candidate,
|
||||
)
|
||||
|
||||
|
||||
LAYOUT = ReferenceLayout(
|
||||
sample_rate_hz=8_000,
|
||||
symbol_rate=200,
|
||||
carrier_hz=500.0,
|
||||
leading_samples=800,
|
||||
segment_samples=800,
|
||||
gap_samples=400,
|
||||
trailing_samples=800,
|
||||
)
|
||||
CRITERIA = SearchCriteria(
|
||||
decimation=1,
|
||||
input_block_samples=50_000,
|
||||
cfo_min_hz=-3.0,
|
||||
cfo_max_hz=3.0,
|
||||
cfo_step_hz=0.5,
|
||||
histogram_stride=8,
|
||||
)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXACT_REFERENCE_PATH = (
|
||||
PROJECT_ROOT / ".local" / "rf001_probe" / "lab065_reference_staircase.cs8"
|
||||
)
|
||||
|
||||
|
||||
def _reference() -> np.ndarray:
|
||||
random_generator = np.random.default_rng(20260925)
|
||||
symbols = random_generator.choice((-1.0, 1.0), LAYOUT.segment_samples // 40)
|
||||
base = np.repeat(symbols, 40).astype(np.complex64)
|
||||
time_axis = np.arange(LAYOUT.segment_samples) / LAYOUT.sample_rate_hz
|
||||
base *= np.exp(2j * np.pi * LAYOUT.carrier_hz * time_axis)
|
||||
base *= 60.0 / np.sqrt(np.mean(np.abs(base) ** 2))
|
||||
values = np.zeros(LAYOUT.total_samples, dtype=np.complex64)
|
||||
for level, (start, stop) in zip(LAYOUT.levels_db, LAYOUT.active_windows()):
|
||||
values[start:stop] = base * (10 ** (level / 20))
|
||||
return values
|
||||
|
||||
|
||||
def _write_cs8(path: Path, values: np.ndarray) -> str:
|
||||
interleaved = np.empty(2 * values.size, dtype=np.int8)
|
||||
interleaved[0::2] = np.clip(np.rint(values.real), -128, 127).astype(np.int8)
|
||||
interleaved[1::2] = np.clip(np.rint(values.imag), -128, 127).astype(np.int8)
|
||||
path.write_bytes(interleaved.tobytes())
|
||||
return hashlib.sha256(interleaved.tobytes()).hexdigest()
|
||||
|
||||
|
||||
def _capture(reference: np.ndarray, *, gain: float, cfo_hz: float, start: int) -> np.ndarray:
|
||||
random_generator = np.random.default_rng(417)
|
||||
values = (
|
||||
random_generator.normal(0.0, 1.5, 1_000_000)
|
||||
+ 1j * random_generator.normal(0.0, 1.5, 1_000_000)
|
||||
).astype(np.complex64)
|
||||
time_axis = np.arange(reference.size) / LAYOUT.sample_rate_hz
|
||||
values[start:start + reference.size] += (
|
||||
gain
|
||||
* reference
|
||||
* np.exp(1j * (0.73 + 2 * np.pi * cfo_hz * time_axis))
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("gain", "cfo_hz", "start"),
|
||||
((0.25, -2.5, 49_777), (0.75, 0.0, 420_201), (1.5, 2.5, 749_999)),
|
||||
)
|
||||
def test_unknown_position_gain_cfo_and_block_boundary(
|
||||
tmp_path: Path,
|
||||
gain: float,
|
||||
cfo_hz: float,
|
||||
start: int,
|
||||
) -> None:
|
||||
reference_path = tmp_path / "reference.cs8"
|
||||
capture_path = tmp_path / "capture.cs8"
|
||||
reference_hash = _write_cs8(reference_path, _reference())
|
||||
_write_cs8(capture_path, _capture(_reference(), gain=gain, cfo_hz=cfo_hz, start=start))
|
||||
reference, _ = load_reference(
|
||||
reference_path,
|
||||
LAYOUT,
|
||||
CRITERIA,
|
||||
expected_sha256=reference_hash,
|
||||
)
|
||||
search = locate_reference_streaming(capture_path, reference, LAYOUT, CRITERIA)
|
||||
candidate = search["candidates"][0]
|
||||
verification = verify_candidate(capture_path, reference, candidate, LAYOUT, CRITERIA)
|
||||
assert abs(int(candidate["start_sample"]) - start) <= 1
|
||||
assert abs(float(candidate["cfo_hz"]) - cfo_hz) <= CRITERIA.cfo_step_hz
|
||||
assert float(candidate["score"]) >= CRITERIA.normalized_correlation_minimum
|
||||
assert verification["qualified"] is True
|
||||
|
||||
|
||||
def test_negative_control_without_staircase_is_not_detected(tmp_path: Path) -> None:
|
||||
reference_path = tmp_path / "reference.cs8"
|
||||
capture_path = tmp_path / "noise.cs8"
|
||||
report_path = tmp_path / "report.json"
|
||||
reference_hash = _write_cs8(reference_path, _reference())
|
||||
random_generator = np.random.default_rng(818)
|
||||
noise = (
|
||||
random_generator.normal(0.0, 3.0, 1_000_000)
|
||||
+ 1j * random_generator.normal(0.0, 3.0, 1_000_000)
|
||||
).astype(np.complex64)
|
||||
capture_hash = _write_cs8(capture_path, noise)
|
||||
result = analyze(
|
||||
reference_path,
|
||||
capture_path,
|
||||
report_path,
|
||||
expected_capture_sha256=capture_hash,
|
||||
layout=LAYOUT,
|
||||
criteria=CRITERIA,
|
||||
expected_reference_sha256=reference_hash,
|
||||
)
|
||||
assert result["verdict"] == "NOT_DETECTED"
|
||||
assert report_path.is_file()
|
||||
assert result["hardware_accessed"] is False
|
||||
assert result["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_report_is_not_overwritten(tmp_path: Path) -> None:
|
||||
reference_path = tmp_path / "reference.cs8"
|
||||
capture_path = tmp_path / "capture.cs8"
|
||||
report_path = tmp_path / "report.json"
|
||||
reference_hash = _write_cs8(reference_path, _reference())
|
||||
capture_hash = _write_cs8(
|
||||
capture_path,
|
||||
_capture(_reference(), gain=1.0, cfo_hz=0.0, start=4_000),
|
||||
)
|
||||
result = analyze(
|
||||
reference_path,
|
||||
capture_path,
|
||||
report_path,
|
||||
expected_capture_sha256=capture_hash,
|
||||
layout=LAYOUT,
|
||||
criteria=CRITERIA,
|
||||
expected_reference_sha256=reference_hash,
|
||||
)
|
||||
assert result["verdict"] == "DETECTED"
|
||||
with pytest.raises(FileExistsError):
|
||||
analyze(
|
||||
reference_path,
|
||||
capture_path,
|
||||
report_path,
|
||||
expected_capture_sha256=capture_hash,
|
||||
layout=LAYOUT,
|
||||
criteria=CRITERIA,
|
||||
expected_reference_sha256=reference_hash,
|
||||
)
|
||||
|
||||
|
||||
def _require_exact_reference() -> Path:
|
||||
if not EXACT_REFERENCE_PATH.is_file():
|
||||
pytest.skip("exact Lab065 reference is not available in this checkout")
|
||||
assert EXACT_REFERENCE_PATH.stat().st_size == 12_000_000
|
||||
assert hashlib.sha256(EXACT_REFERENCE_PATH.read_bytes()).hexdigest() == (
|
||||
EXPECTED_REFERENCE_SHA256
|
||||
)
|
||||
return EXACT_REFERENCE_PATH
|
||||
|
||||
|
||||
def _read_exact_reference_values(path: Path) -> np.ndarray:
|
||||
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||
return (
|
||||
np.asarray(raw[0::2], dtype=np.float32)
|
||||
+ 1j * np.asarray(raw[1::2], dtype=np.float32)
|
||||
).astype(np.complex64)
|
||||
|
||||
|
||||
def _write_noise_cs8(path: Path, samples: int, seed: int) -> None:
|
||||
random_generator = np.random.default_rng(seed)
|
||||
raw = np.memmap(path, dtype=np.int8, mode="w+", shape=(2 * samples,))
|
||||
block = 500_000
|
||||
for start in range(0, samples, block):
|
||||
stop = min(samples, start + block)
|
||||
count = stop - start
|
||||
raw[2 * start:2 * stop:2] = np.clip(
|
||||
np.rint(random_generator.normal(0.0, 0.75, count)),
|
||||
-128,
|
||||
127,
|
||||
).astype(np.int8)
|
||||
raw[2 * start + 1:2 * stop + 1:2] = np.clip(
|
||||
np.rint(random_generator.normal(0.0, 0.75, count)),
|
||||
-128,
|
||||
127,
|
||||
).astype(np.int8)
|
||||
raw.flush()
|
||||
del raw
|
||||
|
||||
|
||||
def _inject_exact_reference(
|
||||
capture_path: Path,
|
||||
reference: np.ndarray,
|
||||
*,
|
||||
start: int,
|
||||
gain: float,
|
||||
phase: float,
|
||||
cfo_hz: float,
|
||||
) -> None:
|
||||
raw = np.memmap(capture_path, dtype=np.int8, mode="r+")
|
||||
existing = (
|
||||
np.asarray(raw[2 * start:2 * (start + reference.size):2], dtype=np.float32)
|
||||
+ 1j
|
||||
* np.asarray(
|
||||
raw[2 * start + 1:2 * (start + reference.size) + 1:2],
|
||||
dtype=np.float32,
|
||||
)
|
||||
)
|
||||
time_axis = np.arange(reference.size, dtype=np.float64) / DEFAULT_LAYOUT.sample_rate_hz
|
||||
injected = existing + gain * reference * np.exp(
|
||||
1j * (phase + 2 * np.pi * cfo_hz * time_axis)
|
||||
)
|
||||
raw[2 * start:2 * (start + reference.size):2] = np.clip(
|
||||
np.rint(injected.real), -128, 127
|
||||
).astype(np.int8)
|
||||
raw[2 * start + 1:2 * (start + reference.size) + 1:2] = np.clip(
|
||||
np.rint(injected.imag), -128, 127
|
||||
).astype(np.int8)
|
||||
raw.flush()
|
||||
del raw
|
||||
|
||||
|
||||
def test_antialias_filter_and_block_overlap_are_fixed() -> None:
|
||||
reference_path = _require_exact_reference()
|
||||
taps = design_antialias_filter(DEFAULT_LAYOUT, DEFAULT_CRITERIA)
|
||||
assert taps.size == 895
|
||||
assert (taps.size - 1) // 2 == 447
|
||||
frequencies, response = signal.freqz(
|
||||
taps,
|
||||
worN=262_144,
|
||||
fs=DEFAULT_LAYOUT.sample_rate_hz,
|
||||
)
|
||||
assert np.max(np.abs(response[frequencies >= 120_000])) <= 10 ** (-79 / 20)
|
||||
|
||||
total = math.ceil(DEFAULT_LAYOUT.total_samples / DEFAULT_CRITERIA.decimation)
|
||||
split = 73_219
|
||||
whole = read_filtered_decimated_window(
|
||||
reference_path,
|
||||
0,
|
||||
total,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
taps,
|
||||
)
|
||||
blocked = np.concatenate(
|
||||
(
|
||||
read_filtered_decimated_window(
|
||||
reference_path,
|
||||
0,
|
||||
split,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
taps,
|
||||
),
|
||||
read_filtered_decimated_window(
|
||||
reference_path,
|
||||
split,
|
||||
total,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
taps,
|
||||
),
|
||||
)
|
||||
)
|
||||
np.testing.assert_allclose(blocked, whole, rtol=0, atol=2e-5)
|
||||
|
||||
|
||||
def test_exact_reference_unknown_positions_gain_phase_noise_and_cfo(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
reference_path = _require_exact_reference()
|
||||
reference_values = _read_exact_reference_values(reference_path)
|
||||
cases = (
|
||||
(123_457, 0.45, 0.73, -2.5),
|
||||
(7_999_987, 0.70, -1.17, 0.0),
|
||||
(15_999_983, 1.00, 2.21, 2.5),
|
||||
)
|
||||
capture_path = tmp_path / "exact_reference_cases.cs8"
|
||||
_write_noise_cs8(capture_path, 22_500_000, seed=2026092801)
|
||||
for start, gain, phase, cfo_hz in cases:
|
||||
_inject_exact_reference(
|
||||
capture_path,
|
||||
reference_values,
|
||||
start=start,
|
||||
gain=gain,
|
||||
phase=phase,
|
||||
cfo_hz=cfo_hz,
|
||||
)
|
||||
reference, _ = load_reference(
|
||||
reference_path,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
expected_sha256=EXPECTED_REFERENCE_SHA256,
|
||||
)
|
||||
search = locate_reference_streaming(
|
||||
capture_path,
|
||||
reference,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
)
|
||||
assert search["coverage_complete"] is True
|
||||
assert search["source_samples_processed"] == 22_500_000
|
||||
candidates = list(search["candidates"])
|
||||
for expected_start, _gain, _phase, expected_cfo in cases:
|
||||
candidate = min(
|
||||
candidates,
|
||||
key=lambda row: abs(int(row["start_sample"]) - expected_start),
|
||||
)
|
||||
assert abs(int(candidate["start_sample"]) - expected_start) <= 32
|
||||
assert abs(float(candidate["cfo_hz"]) - expected_cfo) <= 0.25
|
||||
assert float(candidate["score"]) >= DEFAULT_CRITERIA.normalized_correlation_minimum
|
||||
verification = verify_candidate(
|
||||
capture_path,
|
||||
reference,
|
||||
candidate,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
)
|
||||
assert verification["qualified"] is True
|
||||
|
||||
|
||||
def test_exact_reference_negative_control(tmp_path: Path) -> None:
|
||||
reference_path = _require_exact_reference()
|
||||
capture_path = tmp_path / "exact_reference_negative.cs8"
|
||||
report_path = tmp_path / "negative_report.json"
|
||||
_write_noise_cs8(capture_path, 8_000_000, seed=2026092802)
|
||||
capture_hash = hashlib.sha256(capture_path.read_bytes()).hexdigest()
|
||||
result = analyze(
|
||||
reference_path,
|
||||
capture_path,
|
||||
report_path,
|
||||
expected_capture_sha256=capture_hash,
|
||||
)
|
||||
assert result["verdict"] == "NOT_DETECTED"
|
||||
assert result["search"]["coverage_complete"] is True
|
||||
Reference in New Issue
Block a user