aps-agent/tests/golden/test_desktop_sidecar.py

371 lines
11 KiB
Python

from __future__ import annotations
import json
import subprocess
import sys
import pytest
from starlette.testclient import TestClient
from server.sidecar import (
PROBE_OK_PREFIX,
NativeProbeResult,
SidecarIdentityApp,
SidecarStartupError,
build_desktop_app,
build_probe_command,
load_config,
main,
normalize_loopback_host,
normalize_nonce,
parent_is_alive,
parse_parent_pid,
run_native_probe,
run_sidecar,
run_solver_child,
watch_parent,
)
def test_sidecar_config_defaults_and_env_overrides() -> None:
assert load_config({}).host == "127.0.0.1"
assert load_config({}).port == 8000
assert load_config({}).ui_dir is None
assert load_config({}).nonce is None
assert load_config({}).parent_pid is None
assert load_config({"APS_API_HOST": "localhost", "APS_API_PORT": "43117"}) == (
load_config({"APS_API_HOST": "127.0.0.1", "APS_API_PORT": "43117"})
)
assert load_config({"APS_API_HOST": "::1", "APS_API_PORT": "65535"}).host == "::1"
@pytest.mark.parametrize(
"host",
["", "0.0.0.0", "192.168.1.20", "example.com", "::", "[::1]"],
)
def test_sidecar_rejects_non_loopback_or_ambiguous_hosts(host: str) -> None:
with pytest.raises(SidecarStartupError):
normalize_loopback_host(host)
@pytest.mark.parametrize("port", ["0", "65536", "not-a-port", "3.14"])
def test_sidecar_rejects_invalid_ports(port: str) -> None:
with pytest.raises(SidecarStartupError):
load_config({"APS_API_PORT": port})
@pytest.mark.parametrize(
"nonce", ["short", "g" * 64, "a" * 63, "a" * 65, "a" * 63 + "\n"]
)
def test_sidecar_rejects_invalid_nonce(nonce: str) -> None:
with pytest.raises(SidecarStartupError):
normalize_nonce(nonce)
@pytest.mark.parametrize("pid", ["bad", "0", "-1", str(__import__("os").getpid())])
def test_sidecar_rejects_invalid_parent_pid(pid: str) -> None:
with pytest.raises(SidecarStartupError):
parse_parent_pid(pid)
def test_parent_liveness_detects_current_process() -> None:
assert parent_is_alive(__import__("os").getpid()) is True
def test_parent_watchdog_exits_after_parent_disappears() -> None:
checks = iter([True, False])
exits: list[int] = []
watch_parent(
12345,
is_alive=lambda _pid: next(checks),
exit_process=exits.append,
interval_seconds=0,
)
assert exits == [0]
def test_probe_command_supports_python_and_frozen_sidecar() -> None:
assert build_probe_command(executable="python", frozen=False) == [
"python",
"-X",
"faulthandler",
"-m",
"server.sidecar",
"--probe-child",
]
assert build_probe_command(executable="aps-sidecar.exe", frozen=True) == [
"aps-sidecar.exe",
"--probe-child",
]
def test_solver_child_uses_protocol_main_with_passthrough_args(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[list[str]] = []
class WorkerModule:
@staticmethod
def main() -> int:
calls.append(sys.argv[1:])
return 17
monkeypatch.setitem(sys.modules, "server.engines.solver_worker", WorkerModule())
original_argv = sys.argv[:]
assert run_solver_child(["--protocol-version", "1"]) == 17
assert calls == [["--protocol-version", "1"]]
assert sys.argv == original_argv
def test_frozen_solver_child_entry_runs_stdin_stdout_protocol_without_web() -> None:
completed = subprocess.run(
[sys.executable, "-m", "server.sidecar", "--solver-child"],
input="{}",
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
timeout=15,
)
assert completed.returncode == 0, completed.stderr
payload = json.loads(completed.stdout)
assert payload["marker"] == "APS_SOLVER_ERROR_V1"
assert payload["error"]["code"] == "SOLVER_PROTOCOL_MISMATCH"
assert "Uvicorn running" not in completed.stderr
def test_source_solver_child_entry_runs_under_isolated_python(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from pathlib import Path
from server.engines.base import EngineParams
from server.engines.solver_process import run_cp_assignment
from server.state.seed import seed_world
from server.timeutil import fmt_date, today0
sidecar_source = Path(__file__).resolve().parents[2] / "server" / "sidecar.py"
command = [
sys.executable,
"-I",
"-B",
"-X",
"utf8",
"-X",
"faulthandler",
str(sidecar_source),
"--solver-child",
]
monkeypatch.setenv("APS_SOLVER_CHILD_COMMAND_JSON", json.dumps(command))
params = EngineParams(
orderIds=[],
engineType="CP",
strategyTemplate="COMPREHENSIVE",
planningHorizonDays=14,
startDate=fmt_date(today0()),
timeLimitSeconds=2.0,
)
ordered, solver_meta = run_cp_assignment(
seed_world(), [], params, pipeline_label="isolated-sidecar-test"
)
assert ordered == []
process_meta = solver_meta["solverProcess"]
assert process_meta["protocolVersion"] == "aps.solver-process.v2"
assert process_meta["runtimeIdentity"]["safe"] is True
def test_main_routes_solver_child_without_probe_or_web(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[list[str]] = []
monkeypatch.setattr(
"server.sidecar.run_solver_child",
lambda argv: calls.append(list(argv)) or 23,
)
monkeypatch.setattr(
"server.sidecar.probe_native_dependencies",
lambda: pytest.fail("solver child must not execute the sidecar probe"),
)
monkeypatch.setattr(
"server.sidecar.run_sidecar",
lambda: pytest.fail("solver child must not start the Web service"),
)
assert main(["--solver-child", "--protocol-version", "1"]) == 23
assert calls == [["--protocol-version", "1"]]
def test_run_native_probe_times_out_fail_closed() -> None:
def runner(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
raise subprocess.TimeoutExpired(cmd=["probe"], timeout=3)
with pytest.raises(SidecarStartupError, match="timed out after 3s"):
run_native_probe(timeout_seconds=3, runner=runner)
def test_run_native_probe_requires_clean_exit_and_success_marker() -> None:
calls: list[tuple[list[str], dict[str, object]]] = []
def runner(
command: list[str], **kwargs: object
) -> subprocess.CompletedProcess[str]:
calls.append((command, kwargs))
return subprocess.CompletedProcess(
command, 0, f"{PROBE_OK_PREFIX} numpy=2 pandas=2", ""
)
result = run_native_probe(timeout_seconds=5, runner=runner)
assert result.stdout.startswith(PROBE_OK_PREFIX)
assert calls[0][0][-2:] == ["server.sidecar", "--probe-child"]
assert calls[0][1]["timeout"] == 5
assert calls[0][1]["check"] is False
@pytest.mark.parametrize(
("completed", "message"),
[
(subprocess.CompletedProcess(["probe"], 7, "", "loader failed"), "code 7"),
(
subprocess.CompletedProcess(
["probe"],
0,
f"{PROBE_OK_PREFIX}\n",
"Windows fatal exception: access violation",
),
"windows fatal exception",
),
(
subprocess.CompletedProcess(["probe"], 0, "imports loaded", ""),
"success marker",
),
],
)
def test_run_native_probe_fails_closed(
completed: subprocess.CompletedProcess[str],
message: str,
) -> None:
with pytest.raises(SidecarStartupError, match=message):
run_native_probe(runner=lambda *args, **kwargs: completed)
def test_run_sidecar_probes_before_uvicorn_and_forwards_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[object] = []
def probe() -> NativeProbeResult:
events.append("probe")
return NativeProbeResult(stdout=PROBE_OK_PREFIX, stderr="")
def uvicorn_runner(app: str, **kwargs: object) -> None:
events.append((app, kwargs))
monkeypatch.delenv("APS_MODE", raising=False)
assert (
run_sidecar(
env={
"APS_API_HOST": "127.0.0.1",
"APS_API_PORT": "43117",
"APS_UI_DIR": "C:/aps/ui",
"APS_SIDECAR_NONCE": "a" * 64,
"APS_PARENT_PID": "4242",
},
probe=probe,
uvicorn_runner=uvicorn_runner,
app_builder=lambda ui_dir, nonce: (
events.append(("ui", ui_dir, nonce)) or "desktop-app"
),
parent_watchdog=lambda pid: events.append(("watchdog", pid)),
)
== 0
)
assert events == [
"probe",
("watchdog", 4242),
("ui", "C:/aps/ui", "a" * 64),
(
"desktop-app",
{"host": "127.0.0.1", "port": 43117, "log_level": "info", "workers": 1},
),
]
assert __import__("os").environ["APS_MODE"] == "desktop"
def test_build_desktop_app_requires_an_index(tmp_path) -> None:
with pytest.raises(SidecarStartupError, match="desktop UI index is missing"):
build_desktop_app(str(tmp_path), None)
def test_build_desktop_app_mounts_same_origin_ui(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "index.html").write_text("<!doctype html>", encoding="utf-8")
mounts: list[tuple[str, object, str]] = []
class FakeApp:
def mount(self, route: str, mounted: object, *, name: str) -> None:
mounts.append((route, mounted, name))
monkeypatch.setattr("server.main.app", FakeApp())
mounted_app = build_desktop_app(str(tmp_path), None)
assert isinstance(mounted_app, FakeApp)
assert mounts[0][0] == "/"
assert mounts[0][2] == "desktop-ui"
def test_sidecar_identity_app_requires_nonce_and_adds_identity_header() -> None:
calls: list[str] = []
async def inner(scope, receive, send) -> None:
calls.append("called")
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
nonce = "b" * 64
client = TestClient(SidecarIdentityApp(inner, nonce))
denied = client.get("/")
wrong = client.get("/", headers={"x-aps-sidecar-nonce": "c" * 64})
response = client.get("/", headers={"x-aps-sidecar-nonce": nonce})
assert denied.status_code == 403
assert wrong.status_code == 403
assert "x-aps-sidecar-nonce" not in denied.headers
assert response.status_code == 200
assert response.headers["x-aps-sidecar-nonce"] == nonce
assert calls == ["called"]
def test_main_rejects_non_loopback_before_starting_probe(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("APS_API_HOST", "0.0.0.0")
assert main([]) == 2
assert "APS_API_HOST must be loopback-only" in capsys.readouterr().err
@pytest.mark.skipif(
sys.platform != "win32", reason="release blocker targets Windows native wheels"
)
def test_native_dependency_probe_smoke() -> None:
result = run_native_probe(timeout_seconds=30)
assert PROBE_OK_PREFIX in result.stdout
assert "numpy=" in result.stdout
assert "pandas=" in result.stdout
assert "ortools=" in result.stdout
assert "python=" in result.stdout
assert "machine=" in result.stdout
assert "runtimeIdentity" in result.stdout
assert "baseExecutable" in result.stdout
assert "ENABLE_USER_SITE" in result.stdout
assert '"path"' in result.stdout
assert '"version"' in result.stdout