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("", 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