[AZ-317] [AZ-318] C11 upload-side: flight-state gate + per-flight key

Batch 38 (cycle 1) lands the two upload-side prerequisites the
upcoming AZ-319 TileUploader needs to authenticate per-flight
sessions against the parent suite's D-PROJ-2 ingest contract.

AZ-317 FlightStateGate:
- confirm_on_ground() defence-in-depth gate atop ADR-004 process
  isolation; fail-closed for UNKNOWN, IN_FLIGHT, TAKING_OFF,
  LANDING, and source-failure (mapped to UNKNOWN with original
  exception preserved on __cause__).
- ERROR log on refusal, INFO log on pass, single source call per
  invocation (no polling, no retry).

AZ-318 PerFlightKeyManager:
- Per-flight ephemeral Ed25519 keypair via the project-pinned
  cryptography library; sign(payload) -> 64-byte Ed25519 signature.
- Best-effort zeroisation of a project-controlled bytearray mirror
  on end_session; OpenSSL-side buffer freed via dropped reference.
- __del__ safety net with WARN log if end_session was missed.
- start_session emits FDR kind=c11.upload.session.key.public so the
  safety officer can correlate flights with key fingerprints.
- record_signature_rejection emits FDR + ERROR log on parent-suite
  ingest rejection (security-critical, never silently dropped).

Shared C11 plumbing:
- TileManagerError parent + 3 subclasses (FlightStateNotOnGroundError,
  SessionNotActiveError, SignatureRejectedError envelope).
- FlightStateSignal (str, Enum) and PublicKeyFingerprint DTOs.
- FlightStateSource Protocol on c11_tile_manager.interface.
- runtime_root.c11_factory factories for both new services.
- Two new FDR kinds registered in fdr_client.records central
  KNOWN_PAYLOAD_KEYS; AZ-272 schema-roundtrip fixtures added in
  lockstep so the central test stays green.

Tests: 26 new + 2 fixture additions; full suite 1384 passed, 80
skipped (documented Docker / Tier-2 / CUDA gates).

Code review: PASS_WITH_WARNINGS — 2 Low findings documented in
_docs/03_implementation/reviews/batch_38_review.md (dev-host vs
operator-workstation perf bound; spec text named StrEnum but
project pins Python 3.10).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 05:48:52 +03:00
parent ca0430a44d
commit cde237e236
16 changed files with 1936 additions and 8 deletions
@@ -0,0 +1,297 @@
"""AZ-317 ``FlightStateGate`` unit tests.
Covers all eight acceptance criteria + NFRs from
``_docs/02_tasks/done/AZ-317_c11_flight_state_gate.md`` (after the
batch-38 archive). Uses a hand-rolled fake :class:`FlightStateSource`
and a list-backed log handler so assertions stay close to the
captured records.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime, timezone
import pytest
from gps_denied_onboard.components.c11_tile_manager import (
FlightStateGate,
FlightStateNotOnGroundError,
FlightStateSignal,
FlightStateSource,
)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
class _FakeSource:
"""Hand-rolled :class:`FlightStateSource` returning a fixed signal.
Spies on every ``current_flight_state`` call so AC-8 can assert
the gate calls the source exactly once per ``confirm_on_ground``.
"""
def __init__(self, signal: FlightStateSignal) -> None:
self._signal = signal
self.call_count = 0
def current_flight_state(self) -> FlightStateSignal:
self.call_count += 1
return self._signal
class _RaisingSource:
""":class:`FlightStateSource` whose ``current_flight_state`` raises."""
def __init__(self, exc: Exception) -> None:
self._exc = exc
self.call_count = 0
def current_flight_state(self) -> FlightStateSignal:
self.call_count += 1
raise self._exc
class _PartialFake:
"""Type stub WITHOUT ``current_flight_state`` for AC-6 negative case."""
def something_else(self) -> str:
return "noop"
def _build_gate(
*,
source: FlightStateSource,
) -> tuple[FlightStateGate, list[logging.LogRecord]]:
records: list[logging.LogRecord] = []
class _ListHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
logger = logging.getLogger(f"test_az317_{id(records)}")
logger.handlers.clear()
logger.addHandler(_ListHandler())
logger.setLevel(logging.DEBUG)
logger.propagate = False
return FlightStateGate(source=source, logger=logger), records
def _kinds(records: list[logging.LogRecord]) -> list[str]:
return [getattr(r, "kind", None) for r in records]
# ----------------------------------------------------------------------
# AC-1: ON_GROUND passes
# ----------------------------------------------------------------------
def test_ac1_on_ground_returns_signal_and_emits_info_log() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.ON_GROUND)
gate, records = _build_gate(source=source)
# Act
result = gate.confirm_on_ground()
# Assert
assert result is FlightStateSignal.ON_GROUND
assert _kinds(records) == ["c11.upload.flight_state_confirmed"]
assert records[0].levelname == "INFO"
assert source.call_count == 1
# ----------------------------------------------------------------------
# AC-2: IN_FLIGHT raises
# ----------------------------------------------------------------------
def test_ac2_in_flight_raises_with_observed_and_error_log() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
gate, records = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
assert excinfo.value.observed is FlightStateSignal.IN_FLIGHT
assert "IN_FLIGHT" in str(excinfo.value)
assert _kinds(records) == ["c11.upload.refused.flight_state"]
assert records[0].levelname == "ERROR"
# ----------------------------------------------------------------------
# AC-3: UNKNOWN raises (fail-closed)
# ----------------------------------------------------------------------
def test_ac3_unknown_raises_fail_closed() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.UNKNOWN)
gate, records = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
assert excinfo.value.observed is FlightStateSignal.UNKNOWN
assert _kinds(records) == ["c11.upload.refused.flight_state"]
# ----------------------------------------------------------------------
# AC-4: TAKING_OFF and LANDING raise
# ----------------------------------------------------------------------
@pytest.mark.parametrize(
"transition_signal",
[FlightStateSignal.TAKING_OFF, FlightStateSignal.LANDING],
)
def test_ac4_transition_states_raise(
transition_signal: FlightStateSignal,
) -> None:
# Arrange
source = _FakeSource(transition_signal)
gate, records = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
assert excinfo.value.observed is transition_signal
assert _kinds(records) == ["c11.upload.refused.flight_state"]
# ----------------------------------------------------------------------
# AC-5: source exception → UNKNOWN with __cause__ chained
# ----------------------------------------------------------------------
def test_ac5_source_exception_maps_to_unknown_and_preserves_cause() -> None:
# Arrange
original = RuntimeError("FC disconnected")
source = _RaisingSource(original)
gate, records = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
assert excinfo.value.observed is FlightStateSignal.UNKNOWN
assert excinfo.value.__cause__ is original
assert _kinds(records) == ["c11.upload.refused.flight_state"]
assert records[0].levelname == "ERROR"
assert "FC disconnected" in records[0].kv["source_error"]
# ----------------------------------------------------------------------
# AC-6: FlightStateSource Protocol is conformance-checkable
# ----------------------------------------------------------------------
def test_ac6_protocol_isinstance_check_distinguishes_conforming_from_partial() -> None:
# Arrange
conforming = _FakeSource(FlightStateSignal.ON_GROUND)
non_conforming = _PartialFake()
# Assert
assert isinstance(conforming, FlightStateSource)
assert not isinstance(non_conforming, FlightStateSource)
# ----------------------------------------------------------------------
# AC-7: Error carries diagnostic fields
# ----------------------------------------------------------------------
def test_ac7_error_carries_observed_and_observed_at_with_message_format() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
gate, _ = _build_gate(source=source)
# Act
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
# Assert
assert excinfo.value.observed is FlightStateSignal.IN_FLIGHT
assert isinstance(excinfo.value.observed_at, datetime)
assert excinfo.value.observed_at.tzinfo == timezone.utc
assert excinfo.value.observed_at.microsecond == 0
assert str(excinfo.value).startswith("Upload refused: flight state is ")
# ----------------------------------------------------------------------
# AC-8: Gate calls source exactly once
# ----------------------------------------------------------------------
def test_ac8_gate_calls_source_exactly_once_no_retry() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
gate, _ = _build_gate(source=source)
# Act
with pytest.raises(FlightStateNotOnGroundError):
gate.confirm_on_ground()
# Assert
assert source.call_count == 1
# ----------------------------------------------------------------------
# NFR-perf: confirm_on_ground microbench p99 ≤ 1 ms
# ----------------------------------------------------------------------
def test_nfr_perf_microbench_under_one_ms_p99() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.ON_GROUND)
gate, _ = _build_gate(source=source)
iterations = 5_000
# Act
samples_ns: list[int] = []
for _ in range(iterations):
start = time.perf_counter_ns()
gate.confirm_on_ground()
samples_ns.append(time.perf_counter_ns() - start)
# Assert
samples_ns.sort()
p99_ns = samples_ns[int(iterations * 0.99) - 1]
assert p99_ns < 1_000_000, (
f"p99 latency {p99_ns} ns exceeds 1 ms (1_000_000 ns) NFR budget"
)
# ----------------------------------------------------------------------
# NFR-reliability-fail-closed: every non-ON_GROUND state raises
# ----------------------------------------------------------------------
@pytest.mark.parametrize(
"non_on_ground_signal",
[
FlightStateSignal.IN_FLIGHT,
FlightStateSignal.TAKING_OFF,
FlightStateSignal.LANDING,
FlightStateSignal.UNKNOWN,
],
)
def test_nfr_reliability_fail_closed_matrix_complete(
non_on_ground_signal: FlightStateSignal,
) -> None:
# Arrange
source = _FakeSource(non_on_ground_signal)
gate, _ = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError):
gate.confirm_on_ground()
@@ -0,0 +1,414 @@
"""AZ-318 ``PerFlightKeyManager`` unit tests.
Covers all ten acceptance criteria + NFRs from
``_docs/02_tasks/done/AZ-318_c11_signing_key.md`` (after the batch-38
archive).
Uses :class:`FakeFdrSink` for FDR capture, a list-backed log handler
for log capture, and a deterministic ``_FixedClock`` for timestamp
assertions.
"""
from __future__ import annotations
import ctypes
import gc
import logging
import time
from uuid import UUID, uuid4
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from gps_denied_onboard.components.c11_tile_manager import (
PerFlightKeyManager,
PublicKeyFingerprint,
SessionNotActiveError,
)
from gps_denied_onboard.fdr_client import FdrRecord
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
_PRODUCER_ID = "c11_tile_manager.signing_key"
class _FixedClock:
""":class:`Clock` impl returning a fixed wall-clock time."""
def __init__(self, time_ns: int = 1_700_000_000_000_000_000) -> None:
self._time_ns = time_ns
self._mono = 0
def monotonic_ns(self) -> int:
self._mono += 1
return self._mono
def time_ns(self) -> int:
return self._time_ns
def sleep_until_ns(self, target_ns: int) -> None:
return
def _build_manager() -> tuple[PerFlightKeyManager, FakeFdrSink, list[logging.LogRecord]]:
fdr = FakeFdrSink(_PRODUCER_ID)
records: list[logging.LogRecord] = []
class _ListHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
logger = logging.getLogger(f"test_az318_{id(records)}")
logger.handlers.clear()
logger.addHandler(_ListHandler())
logger.setLevel(logging.DEBUG)
logger.propagate = False
manager = PerFlightKeyManager(
fdr_client=fdr,
logger=logger,
clock=_FixedClock(),
)
return manager, fdr, records
def _kinds(records: list[FdrRecord]) -> list[str]:
return [r.kind for r in records]
def _log_kinds(records: list[logging.LogRecord]) -> list[str]:
return [getattr(r, "kind", None) for r in records]
# ----------------------------------------------------------------------
# AC-1: start_session generates fresh keypair, emits FDR + INFO log
# ----------------------------------------------------------------------
def test_ac1_start_session_emits_public_key_fdr_and_info_log() -> None:
# Arrange
manager, fdr, log_records = _build_manager()
flight_id = uuid4()
# Act
fingerprint = manager.start_session(flight_id)
# Assert
assert isinstance(fingerprint, PublicKeyFingerprint)
assert len(fingerprint.fingerprint) == 16
int(fingerprint.fingerprint, 16)
assert manager.is_active
fdr_records = fdr.records
assert _kinds(fdr_records) == ["c11.upload.session.key.public"]
payload = fdr_records[0].payload
assert payload["flight_id"] == str(flight_id)
assert payload["fingerprint"] == fingerprint.fingerprint
assert "BEGIN PUBLIC KEY" in payload["public_key_pem"]
assert _log_kinds(log_records) == ["c11.upload.session.key.generated"]
info_log = log_records[0]
assert info_log.levelname == "INFO"
assert info_log.kv == {
"flight_id": str(flight_id),
"fingerprint": fingerprint.fingerprint,
}
# ----------------------------------------------------------------------
# AC-2: two sessions produce different fingerprints
# ----------------------------------------------------------------------
def test_ac2_two_sessions_produce_distinct_fingerprints_and_two_fdr_records() -> None:
# Arrange
manager, fdr, _ = _build_manager()
f1 = uuid4()
f2 = uuid4()
# Act
fp1 = manager.start_session(f1)
manager.end_session()
fp2 = manager.start_session(f2)
# Assert
assert fp1.fingerprint != fp2.fingerprint
assert _kinds(fdr.records) == [
"c11.upload.session.key.public",
"c11.upload.session.key.public",
]
# ----------------------------------------------------------------------
# AC-3: sign returns 64-byte Ed25519 signature, verifies against public key
# ----------------------------------------------------------------------
def test_ac3_sign_returns_64_byte_signature_that_verifies() -> None:
# Arrange
manager, _, _ = _build_manager()
fingerprint = manager.start_session(uuid4())
payload = b"hello world"
# Act
sig = manager.sign(payload)
# Assert
assert isinstance(sig, bytes)
assert len(sig) == 64
public_key = serialization.load_pem_public_key(fingerprint.public_key_pem)
assert isinstance(public_key, Ed25519PublicKey)
public_key.verify(sig, payload)
# ----------------------------------------------------------------------
# AC-4: sign before start_session raises
# ----------------------------------------------------------------------
def test_ac4_sign_without_session_raises() -> None:
# Arrange
manager, _, _ = _build_manager()
# Act + Assert
with pytest.raises(SessionNotActiveError):
manager.sign(b"unauthorised")
# ----------------------------------------------------------------------
# AC-5: sign after end_session raises
# ----------------------------------------------------------------------
def test_ac5_sign_after_end_session_raises() -> None:
# Arrange
manager, _, _ = _build_manager()
manager.start_session(uuid4())
manager.end_session()
# Act + Assert
with pytest.raises(SessionNotActiveError):
manager.sign(b"too late")
# ----------------------------------------------------------------------
# AC-6: end_session zeroises the secret buffer
# ----------------------------------------------------------------------
def test_ac6_end_session_zeroises_secret_buffer_and_emits_log() -> None:
# Arrange
manager, _, log_records = _build_manager()
manager.start_session(uuid4())
buffer_address = manager.secret_buffer_address
assert buffer_address is not None
pre_zeroise = ctypes.string_at(buffer_address, 32)
assert pre_zeroise != b"\x00" * 32
# Act
manager.end_session()
post_zeroise = ctypes.string_at(buffer_address, 32)
# Assert
assert post_zeroise == b"\x00" * 32
assert "c11.upload.session.key.zeroised" in _log_kinds(log_records)
assert manager.secret_buffer_address is None
assert not manager.is_active
# ----------------------------------------------------------------------
# AC-7: __del__ safety net zeroises if end_session was missed
# ----------------------------------------------------------------------
def test_ac7_del_safety_net_zeroises_and_emits_warn_log() -> None:
# Arrange
fdr = FakeFdrSink(_PRODUCER_ID)
log_records: list[logging.LogRecord] = []
class _ListHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
log_records.append(record)
logger = logging.getLogger("test_az318_del_safety")
logger.handlers.clear()
logger.addHandler(_ListHandler())
logger.setLevel(logging.DEBUG)
logger.propagate = False
manager = PerFlightKeyManager(
fdr_client=fdr,
logger=logger,
clock=_FixedClock(),
)
manager.start_session(uuid4())
buffer_address = manager.secret_buffer_address
assert buffer_address is not None
# Act
del manager
gc.collect()
# Assert
assert "c11.upload.session.key.zeroised_via_finalizer" in _log_kinds(log_records)
# ----------------------------------------------------------------------
# AC-8: record_signature_rejection emits FDR + ERROR log
# ----------------------------------------------------------------------
def test_ac8_record_signature_rejection_emits_fdr_and_error_log() -> None:
# Arrange
manager, fdr, log_records = _build_manager()
flight_id = uuid4()
manager.start_session(flight_id)
tile_id = "tile-z18-50.0-36.0"
# Act
manager.record_signature_rejection(flight_id, tile_id)
# Assert
rejection_records = [
r for r in fdr.records if r.kind == "c11.upload.signature_rejected"
]
assert len(rejection_records) == 1
payload = rejection_records[0].payload
assert payload["flight_id"] == str(flight_id)
assert payload["tile_id"] == tile_id
assert payload["fingerprint"]
assert "observed_at_iso" in payload
error_logs = [r for r in log_records if r.levelname == "ERROR"]
assert len(error_logs) == 1
assert error_logs[0].kv == payload
# ----------------------------------------------------------------------
# AC-9: Private key never appears in any captured stream
# ----------------------------------------------------------------------
def test_ac9_private_key_pem_never_appears_in_logs_or_fdr() -> None:
# Arrange
manager, fdr, log_records = _build_manager()
manager.start_session(uuid4())
manager.sign(b"payload-1")
manager.record_signature_rejection(uuid4(), "tile-1")
manager.end_session()
# Act
full_stream = b""
for fdr_record in fdr.records:
full_stream += repr(fdr_record).encode()
for log_record in log_records:
full_stream += log_record.getMessage().encode()
full_stream += repr(getattr(log_record, "kv", {})).encode()
# Assert
assert b"BEGIN PRIVATE KEY" not in full_stream
assert b"PRIVATE" not in full_stream or b"PUBLIC" in full_stream
# ----------------------------------------------------------------------
# AC-10: end_session is idempotent
# ----------------------------------------------------------------------
def test_ac10_end_session_idempotent_no_second_log() -> None:
# Arrange
manager, _, log_records = _build_manager()
manager.start_session(uuid4())
manager.end_session()
log_count_after_first_end = len(
[r for r in log_records if getattr(r, "kind", None) == "c11.upload.session.key.zeroised"]
)
# Act
manager.end_session()
# Assert
log_count_after_second_end = len(
[r for r in log_records if getattr(r, "kind", None) == "c11.upload.session.key.zeroised"]
)
assert log_count_after_second_end == log_count_after_first_end
# ----------------------------------------------------------------------
# NFR-perf-sign: microbench p99 ≤ 200 µs
# ----------------------------------------------------------------------
def test_nfr_perf_sign_microbench_p99_under_one_ms() -> None:
# Arrange
# Spec NFR (AZ-318 §Performance): sign p99 ≤ 200 µs on the
# operator workstation. The dev-host bound here is intentionally
# looser (1 ms) so this test stays portable across CI and laptop
# runs; the strict 200 µs budget is verified separately on the
# operator workstation Tier-1 host (manual run, not in CI).
# See AZ-318 Risk-2 / "Performance" section.
manager, _, _ = _build_manager()
manager.start_session(uuid4())
payload = b"x" * 256
warmup_iterations = 200
iterations = 2_000
for _ in range(warmup_iterations):
manager.sign(payload)
# Act
samples_ns: list[int] = []
for _ in range(iterations):
start = time.perf_counter_ns()
manager.sign(payload)
samples_ns.append(time.perf_counter_ns() - start)
manager.end_session()
# Assert
samples_ns.sort()
p99_ns = samples_ns[int(iterations * 0.99) - 1]
assert p99_ns < 1_000_000, (
f"sign p99 latency {p99_ns} ns exceeds dev-host bound of 1 ms "
f"(spec NFR is 200 µs on operator workstation)"
)
# ----------------------------------------------------------------------
# NFR-reliability-fingerprint-uniqueness: 200 sessions all distinct
# ----------------------------------------------------------------------
def test_nfr_reliability_fingerprint_uniqueness_1000_sessions() -> None:
# Arrange
manager, _, _ = _build_manager()
fingerprints: set[str] = set()
# Act
for _ in range(1000):
fp = manager.start_session(uuid4())
fingerprints.add(fp.fingerprint)
manager.end_session()
# Assert
assert len(fingerprints) == 1000
# ----------------------------------------------------------------------
# Defensive: record_signature_rejection without active session raises
# ----------------------------------------------------------------------
def test_record_signature_rejection_without_session_raises() -> None:
# Arrange
manager, _, _ = _build_manager()
# Act + Assert
with pytest.raises(SessionNotActiveError):
manager.record_signature_rejection(uuid4(), "tile-1")