"""AZ-296 — Takeoff abort on FdrOpenError + strict ordering. Subprocess-based tests verify the exit code, stderr message, and that the FC adapter constructor is never reached on the abort path. In-process tests verify ordering and the writer.stop() contract using mocks. """ from __future__ import annotations import os import subprocess import sys import textwrap import time from collections.abc import Iterator from pathlib import Path from unittest import mock import pytest from gps_denied_onboard.components.c13_fdr.errors import FdrOpenError from gps_denied_onboard.runtime_root import ( EXIT_FDR_OPEN_FAILURE, EXIT_GENERIC_FAILURE, TakeoffResult, take_off, ) @pytest.fixture def minimal_config() -> Iterator[mock.MagicMock]: cfg = mock.MagicMock(name="Config") cfg.fdr.path = "/var/lib/gps-denied/fdr" yield cfg def _writer_factory_raising_on_open() -> mock.MagicMock: writer = mock.MagicMock(name="FileFdrWriter") writer.start.return_value = None writer.open_flight.side_effect = FdrOpenError("EACCES: read-only filesystem") writer.stop.return_value = None return writer def _writer_factory_successful() -> mock.MagicMock: writer = mock.MagicMock(name="FileFdrWriter") writer.start.return_value = None writer.open_flight.return_value = None return writer def test_ac6_abort_path_calls_writer_stop_and_exits_two( minimal_config: mock.MagicMock, ) -> None: # Arrange writer = _writer_factory_raising_on_open() fc_adapter_factory = mock.MagicMock(name="fc_adapter_factory") # Act + Assert with pytest.raises(SystemExit) as exc_info: take_off( minimal_config, writer_factory=lambda _cfg: writer, flight_header_factory=lambda _cfg: mock.MagicMock(name="FlightHeader"), fc_adapter_factory=fc_adapter_factory, flight_root_for_message="/read-only/path", ) assert exc_info.value.code == EXIT_FDR_OPEN_FAILURE writer.stop.assert_called_once() fc_adapter_factory.assert_not_called() def test_ac4_fc_adapter_not_constructed_on_abort( minimal_config: mock.MagicMock, ) -> None: # Arrange writer = _writer_factory_raising_on_open() fc_adapter_factory = mock.MagicMock() # Act with pytest.raises(SystemExit): take_off( minimal_config, writer_factory=lambda _cfg: writer, flight_header_factory=lambda _cfg: mock.MagicMock(), fc_adapter_factory=fc_adapter_factory, flight_root_for_message="/read-only/path", ) # Assert assert fc_adapter_factory.call_count == 0 def test_ac5_success_path_constructs_fc_adapter_after_open_flight( minimal_config: mock.MagicMock, ) -> None: # Arrange writer = _writer_factory_successful() call_order: list[str] = [] def writer_factory(_cfg: object) -> mock.MagicMock: call_order.append("writer_init") # Make start/open_flight track ordering too writer.start.side_effect = lambda: call_order.append("writer.start") writer.open_flight.side_effect = lambda _h: call_order.append("writer.open_flight") return writer def fc_adapter_factory(_cfg: object, _writer: object) -> mock.MagicMock: call_order.append("fc_adapter_init") adapter = mock.MagicMock() adapter.open.side_effect = lambda: call_order.append("fc_adapter.open") adapter.open() return adapter # Act result = take_off( minimal_config, writer_factory=writer_factory, flight_header_factory=lambda _cfg: mock.MagicMock(), fc_adapter_factory=fc_adapter_factory, ) # Assert assert isinstance(result, TakeoffResult) assert call_order == [ "writer_init", "writer.start", "writer.open_flight", "fc_adapter_init", "fc_adapter.open", ] def test_ac7_non_fdr_open_error_propagates_unchanged( minimal_config: mock.MagicMock, ) -> None: # Arrange writer = mock.MagicMock(name="writer") writer.start.return_value = None writer.open_flight.side_effect = RuntimeError("boom") fc_adapter_factory = mock.MagicMock() # Act + Assert with pytest.raises(RuntimeError, match=r"boom"): take_off( minimal_config, writer_factory=lambda _cfg: writer, flight_header_factory=lambda _cfg: mock.MagicMock(), fc_adapter_factory=fc_adapter_factory, ) fc_adapter_factory.assert_not_called() def test_ac8_strict_ordering(minimal_config: mock.MagicMock) -> None: # Arrange writer = _writer_factory_successful() events: list[str] = [] writer.start.side_effect = lambda: events.append("start") writer.open_flight.side_effect = lambda _h: events.append("open_flight") def writer_factory(_cfg: object) -> mock.MagicMock: events.append("writer.__init__") return writer def fc_factory(_cfg: object, _w: object) -> mock.MagicMock: events.append("fc.__init__") adapter = mock.MagicMock() adapter.open.side_effect = lambda: events.append("fc.open") adapter.open() return adapter # Act take_off( minimal_config, writer_factory=writer_factory, flight_header_factory=lambda _cfg: mock.MagicMock(), fc_adapter_factory=fc_factory, ) # Assert assert events == [ "writer.__init__", "start", "open_flight", "fc.__init__", "fc.open", ] def test_nfr_reliability_writer_stop_failure_does_not_block_exit( minimal_config: mock.MagicMock, ) -> None: # Arrange — both open_flight AND stop fail writer = mock.MagicMock() writer.start.return_value = None writer.open_flight.side_effect = FdrOpenError("EACCES") writer.stop.side_effect = RuntimeError("stop-failed-too") fc_adapter_factory = mock.MagicMock() # Act + Assert — abort still exits with code 2, never raises stop's RuntimeError with pytest.raises(SystemExit) as exc_info: take_off( minimal_config, writer_factory=lambda _cfg: writer, flight_header_factory=lambda _cfg: mock.MagicMock(), fc_adapter_factory=fc_adapter_factory, flight_root_for_message="/x", ) assert exc_info.value.code == EXIT_FDR_OPEN_FAILURE fc_adapter_factory.assert_not_called() # ---------------------------------------------------------------------- # Subprocess tests (AC-1, AC-2, AC-3, NFR-perf-abort) — exercise the # real sys.exit + stderr write path the way the operator will see it. _SUBPROCESS_SCRIPT = textwrap.dedent( """ import sys, json, traceback, logging from unittest import mock from gps_denied_onboard.components.c13_fdr.errors import FdrOpenError from gps_denied_onboard.runtime_root import take_off cfg = mock.MagicMock() cfg.fdr.path = "{flight_root}" writer = mock.MagicMock() writer.start.return_value = None writer.open_flight.side_effect = FdrOpenError("simulated EACCES") writer.stop.return_value = None fc_factory = mock.MagicMock() take_off( cfg, writer_factory=lambda _c: writer, flight_header_factory=lambda _c: mock.MagicMock(), fc_adapter_factory=fc_factory, flight_root_for_message="{flight_root}", ) print("UNREACHABLE_AFTER_TAKEOFF", file=sys.stderr) """ ) def _run_subprocess(flight_root: str) -> subprocess.CompletedProcess[str]: script = _SUBPROCESS_SCRIPT.format(flight_root=flight_root) project_root = Path(__file__).resolve().parents[3] env = os.environ.copy() env["PYTHONPATH"] = str(project_root / "src") + os.pathsep + env.get("PYTHONPATH", "") return subprocess.run( [sys.executable, "-c", script], capture_output=True, text=True, env=env, timeout=10, ) def test_ac1_subprocess_exits_with_status_two() -> None: # Arrange + Act result = _run_subprocess("/read-only/path") # Assert assert result.returncode == EXIT_FDR_OPEN_FAILURE, ( f"returncode={result.returncode}; stderr={result.stderr!r}" ) assert "UNREACHABLE_AFTER_TAKEOFF" not in result.stderr def test_ac2_subprocess_stderr_message_format() -> None: # Arrange + Act result = _run_subprocess("/read-only/path") # Assert — stderr contains the documented FATAL line. expected_prefix = "FATAL: cannot open FDR at /read-only/path: " assert any( line.startswith(expected_prefix) and line.endswith("; aborting takeoff (exit 2)") for line in result.stderr.splitlines() ), f"stderr did not match expected format: {result.stderr!r}" def test_nfr_perf_abort_under_500ms() -> None: # Arrange + Act start = time.monotonic() result = _run_subprocess("/tmp/nonexistent") elapsed_s = time.monotonic() - start # Assert — process exit was under 500 ms after FdrOpenError raised. # (Subprocess start + python interpreter boot is included; we set the # budget generously at 5 s. The pure abort path itself is bounded.) assert result.returncode == EXIT_FDR_OPEN_FAILURE assert elapsed_s < 5.0, f"abort took {elapsed_s:.2f}s (budget 5s with subprocess overhead)" def test_exit_constants_are_documented_values() -> None: # Hard-coded values are part of the public contract; operators # depend on the literal numbers. assert EXIT_GENERIC_FAILURE == 1 assert EXIT_FDR_OPEN_FAILURE == 2