[AZ-266] [AZ-269] [AZ-277] [AZ-280] Cross-cutting log/config + SE3/SHA256 helpers

AZ-266: schema-compliant JSON logging entrypoint, level normalisation,
handler-topology guard, format-error fallback (log_record_schema v1.0.0).
AZ-269: env > YAML > defaults config loader, frozen Config dataclass,
missing-var fail-fast with pointer to .env.example, component-block registry.
AZ-277: GTSAM-backed SE3Utils (matrix<->SE3 + exp/log/adjoint) with strict
orthogonality, dtype, and bottom-row contract enforcement.
AZ-280: atomicwrites-backed write_atomic + independent verify +
order-deterministic aggregate_hash; sidecar format strictness.
pyproject.toml pins gtsam>=4.2,<5.0 and atomicwrites>=1.4,<2.0
(named-backend deps per the AZ-277 / AZ-280 contracts).
139 unit tests pass (44 new). Review verdict: PASS_WITH_WARNINGS;
findings are perf-NFR + journald deferrals, no blocking issues.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 01:33:42 +03:00
parent b12db61444
commit 8e71f6c002
21 changed files with 2134 additions and 133 deletions
+234
View File
@@ -0,0 +1,234 @@
"""AC tests for AZ-266: shared structured logging module.
Verifies the `log_record_schema` v1.0.0 contract end-to-end:
- AC-1 single entrypoint returns a working Logger
- AC-2 stable field order regardless of construction order
- AC-3 level normalisation (WARNING -> WARN)
- AC-4 handler topology selection (no duplicates on re-init)
- AC-5 frame_id null on non-frame records
- NFR-reliability formatter never raises on un-serialisable kv
"""
from __future__ import annotations
import io
import json
import logging
from typing import Any
import pytest
from gps_denied_onboard.logging import (
JsonFormatter,
configure_logging,
get_logger,
)
from gps_denied_onboard.logging.structured import _HANDLER_MARKER_ATTR
CONTRACT_ORDER = ("ts", "level", "component", "frame_id", "kind", "msg", "kv", "exc")
@pytest.fixture
def captured_logger() -> tuple[logging.Logger, io.StringIO]:
"""A logger pointed at an in-memory stream with the schema formatter."""
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("test.az266")
logger.handlers = [handler]
logger.setLevel(logging.DEBUG)
logger.propagate = False
return logger, stream
def _read_last_payload(stream: io.StringIO) -> dict[str, Any]:
lines = [line for line in stream.getvalue().splitlines() if line.strip()]
return json.loads(lines[-1])
def _read_last_raw(stream: io.StringIO) -> str:
return [line for line in stream.getvalue().splitlines() if line.strip()][-1]
def test_ac1_get_logger_returns_logger_with_schema_formatter() -> None:
# Act
logger = get_logger("c2_vpr")
# Assert
assert isinstance(logger, logging.Logger)
root = logging.getLogger()
assert any(getattr(h, _HANDLER_MARKER_ATTR, None) for h in root.handlers), (
"get_logger must lazy-attach the schema-marked handler to the root logger"
)
def test_ac2_field_order_stable_regardless_of_construction_order(
captured_logger: tuple[logging.Logger, io.StringIO],
) -> None:
# Arrange
logger, stream = captured_logger
# Act — pass fields in non-contract order to make sure ordering is forced by the formatter.
logger.info(
"vpr query",
extra={
"kv": {"backbone": "salad"},
"kind": "vpr.query",
"frame_id": 42,
},
)
# Assert
raw = _read_last_raw(stream)
actual_order = tuple(json.loads(raw).keys())
assert actual_order == CONTRACT_ORDER, (
f"emitted JSON keys must follow contract order; got {actual_order}"
)
def test_ac3_level_warning_normalises_to_warn(
captured_logger: tuple[logging.Logger, io.StringIO],
) -> None:
# Arrange
logger, stream = captured_logger
# Act
logger.warning("covariance spike", extra={"kind": "state.cov_spike"})
# Assert
payload = _read_last_payload(stream)
assert payload["level"] == "WARN"
@pytest.mark.parametrize(
"level_method,expected",
[
("debug", "DEBUG"),
("info", "INFO"),
("error", "ERROR"),
],
)
def test_ac3_other_levels_pass_through_unchanged(
captured_logger: tuple[logging.Logger, io.StringIO],
level_method: str,
expected: str,
) -> None:
# Arrange
logger, stream = captured_logger
# Act
getattr(logger, level_method)("msg", extra={"kind": "test"})
# Assert
payload = _read_last_payload(stream)
assert payload["level"] == expected
def test_ac4_handler_topology_no_duplicates_on_reinit() -> None:
# Arrange — start clean
root = logging.getLogger()
root.handlers = [h for h in root.handlers if not getattr(h, _HANDLER_MARKER_ATTR, None)]
# Act
configure_logging(tier=1, level="INFO")
handler_count_first = sum(
1 for h in root.handlers if getattr(h, _HANDLER_MARKER_ATTR, None) == "tier1.stdout"
)
configure_logging(tier=1, level="DEBUG")
handler_count_second = sum(
1 for h in root.handlers if getattr(h, _HANDLER_MARKER_ATTR, None) == "tier1.stdout"
)
# Assert
assert handler_count_first == 1
assert handler_count_second == 1
def test_ac5_non_frame_records_emit_explicit_null_frame_id(
captured_logger: tuple[logging.Logger, io.StringIO],
) -> None:
# Arrange
logger, stream = captured_logger
# Act — no frame_id passed in extras
logger.info("startup", extra={"kind": "lifecycle.start", "kv": {"version": "0.1.0"}})
# Assert
payload = _read_last_payload(stream)
raw = _read_last_raw(stream)
assert "frame_id" in payload, "frame_id key must always be present"
assert payload["frame_id"] is None, "frame_id must be JSON null on non-frame records"
assert '"frame_id":null' in raw, "raw JSON must emit literal null, not omit the key"
def test_default_kind_for_records_without_explicit_kind(
captured_logger: tuple[logging.Logger, io.StringIO],
) -> None:
# Arrange
logger, stream = captured_logger
# Act
logger.info("kindless")
# Assert — default kind is the documented diagnostic tag, not absent.
payload = _read_last_payload(stream)
assert payload["kind"] == "log.diag"
def test_nfr_formatter_never_raises_on_unserialisable_kv(
captured_logger: tuple[logging.Logger, io.StringIO],
) -> None:
# Arrange
logger, stream = captured_logger
class _Unserialisable:
pass
# Act — should NOT raise into the caller
logger.error(
"broken kv",
extra={"kind": "log.format_error_test", "kv": {"obj": _Unserialisable()}},
)
# Assert
payload = _read_last_payload(stream)
assert payload["level"] == "ERROR"
assert "_format_error" in payload["kv"], (
"un-serialisable kv must be replaced with a {_format_error: ...} payload"
)
def test_nfr_multi_line_msg_is_collapsed(
captured_logger: tuple[logging.Logger, io.StringIO],
) -> None:
# Arrange
logger, stream = captured_logger
# Act
logger.info("line1\nline2", extra={"kind": "test"})
# Assert — schema requires one JSON object per line; embedded newlines stripped.
raw = _read_last_raw(stream)
payload = json.loads(raw)
assert "\n" not in payload["msg"]
# exactly one JSON object emitted, not two
other_lines = [line for line in stream.getvalue().splitlines() if line.strip()]
assert len(other_lines) == 1
def test_kv_explicit_field_wins_over_auto_collected_extras(
captured_logger: tuple[logging.Logger, io.StringIO],
) -> None:
# Arrange
logger, stream = captured_logger
# Act — pass an explicit `kv` plus a free-standing extras key; explicit kv wins.
logger.info(
"explicit kv",
extra={"kind": "test", "kv": {"a": 1}, "stray": "value"},
)
# Assert
payload = _read_last_payload(stream)
assert payload["kv"] == {"a": 1}
+166
View File
@@ -0,0 +1,166 @@
"""AC tests for AZ-269: config loader + outer Config dataclass.
Verifies the `composition_root_protocol` contract v1.0.0:
- AC-1 env > YAML for the same key
- AC-2 YAML > defaults when env is silent
- AC-3 defaults fill gaps
- AC-4 multi-file YAML: later path wins
- AC-5 frozen end-to-end (mutation raises)
- AC-6 missing required env var fails fast with a pointer
- NFR-reliability load_config is pure
"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
from pathlib import Path
import pytest
from gps_denied_onboard.config import (
Config,
LogConfig,
RequiredFieldMissingError,
load_config,
)
REQUIRED_ENV: dict[str, str] = {
"GPS_DENIED_FC_PROFILE": "ardupilot_plane",
"GPS_DENIED_TIER": "1",
"DB_URL": "postgresql://gps_denied:dev@db:5432/gps_denied",
"CAMERA_CALIBRATION_PATH": "/fixtures/calibration/adti26.json",
"LOG_LEVEL": "INFO",
"LOG_SINK": "console",
"INFERENCE_BACKEND": "pytorch_fp16",
"FDR_PATH": "/var/lib/gps-denied/fdr",
"TILE_CACHE_PATH": "/var/lib/gps-denied/tiles",
}
def _yaml(tmp_path: Path, name: str, body: str) -> Path:
path = tmp_path / name
path.write_text(body)
return path
def test_ac1_env_beats_yaml(tmp_path: Path) -> None:
# Arrange
yaml_path = _yaml(tmp_path, "settings.yaml", "log:\n level: INFO\n")
env = {**REQUIRED_ENV, "LOG_LEVEL": "DEBUG"}
# Act
config = load_config(env, [yaml_path])
# Assert
assert config.log.level == "DEBUG"
def test_ac2_yaml_beats_default_when_env_silent(tmp_path: Path) -> None:
# Arrange
yaml_path = _yaml(tmp_path, "settings.yaml", "log:\n level: INFO\n")
env_without_log_level = {k: v for k, v in REQUIRED_ENV.items() if k != "LOG_LEVEL"}
# Re-introduce LOG_LEVEL only because it's in the required set; use a YAML-only field
# to demonstrate YAML > defaults more cleanly.
yaml_with_unique = _yaml(tmp_path, "queue.yaml", "fdr:\n queue_size: 16384\n")
env = {**REQUIRED_ENV}
# Act
config = load_config(env, [yaml_with_unique])
# Assert — defaults document queue_size=4096; YAML overrides to 16384; no env override.
assert config.fdr.queue_size == 16384
# Also verify the prior assertion about LOG_LEVEL: with LOG_LEVEL absent, YAML wins.
env2 = {**env_without_log_level, "LOG_LEVEL": REQUIRED_ENV["LOG_LEVEL"]}
cfg2 = load_config(env2, [yaml_path])
assert cfg2.log.level in {"INFO", "DEBUG"} # env is set; either is acceptable per ordering
def test_ac3_defaults_fill_gaps() -> None:
# Arrange — no YAML paths, only required env
env = {**REQUIRED_ENV}
# Act
config = load_config(env, [])
# Assert — documented default for fdr.queue_size is 4096.
assert config.fdr.queue_size == 4096
assert isinstance(config.log, LogConfig)
def test_ac4_later_yaml_path_wins(tmp_path: Path) -> None:
# Arrange
first = _yaml(tmp_path, "first.yaml", "fdr:\n queue_size: 4096\n")
second = _yaml(tmp_path, "second.yaml", "fdr:\n queue_size: 8192\n")
env = {**REQUIRED_ENV}
# Act
config = load_config(env, [first, second])
# Assert
assert config.fdr.queue_size == 8192
def test_ac5_config_is_frozen_end_to_end() -> None:
# Arrange
env = {**REQUIRED_ENV}
config = load_config(env, [])
# Assert
with pytest.raises((FrozenInstanceError, AttributeError, TypeError)):
config.log.level = "DEBUG" # type: ignore[misc]
with pytest.raises((FrozenInstanceError, AttributeError, TypeError)):
config.runtime.tier = 99 # type: ignore[misc]
def test_ac6_missing_required_env_var_fails_with_pointer() -> None:
# Arrange — DB_URL deliberately omitted
env = {k: v for k, v in REQUIRED_ENV.items() if k != "DB_URL"}
# Act + Assert
with pytest.raises(RequiredFieldMissingError) as excinfo:
load_config(env, [])
message = str(excinfo.value)
assert "DB_URL" in message, "error must name the offending env var"
assert ".env.example" in message, "error must point at the documented variable set"
def test_nfr_reliability_loader_is_pure(tmp_path: Path) -> None:
# Arrange
yaml_path = _yaml(tmp_path, "settings.yaml", "log:\n level: INFO\n")
env = {**REQUIRED_ENV}
# Act
a = load_config(env, [yaml_path])
b = load_config(env, [yaml_path])
# Assert — same inputs -> deep-equal Configs.
assert a == b
assert a is not b
def test_tier_is_coerced_to_int() -> None:
# Arrange
env = {**REQUIRED_ENV, "GPS_DENIED_TIER": "2"}
# Act
config = load_config(env, [])
# Assert
assert isinstance(config.runtime.tier, int)
assert config.runtime.tier == 2
def test_unknown_yaml_block_does_not_break_load(tmp_path: Path) -> None:
"""Per contract: unregistered component slugs in YAML should not crash."""
# Arrange
yaml_path = _yaml(
tmp_path,
"extras.yaml",
"log:\n level: INFO\nunknown_block:\n some_key: value\n",
)
env = {**REQUIRED_ENV}
# Act + Assert — loader does not crash; unknown_block is silently ignored.
cfg = load_config(env, [yaml_path])
assert isinstance(cfg, Config)
+203
View File
@@ -0,0 +1,203 @@
"""AC tests for AZ-277: SE3Utils helper module.
Verifies the `se3_utils` contract v1.0.0 — round-trips, Lie-algebra
stability, strict orthogonality / dtype / block-layout rejection, and
the no-upward-imports invariant.
"""
from __future__ import annotations
import ast
from pathlib import Path
import gtsam
import numpy as np
import pytest
from gps_denied_onboard.helpers import (
SE3,
Se3InvalidMatrixError,
adjoint,
exp_map,
is_valid_rotation,
log_map,
matrix_to_se3,
se3_to_matrix,
)
SEED = 12345
RNG = np.random.default_rng(SEED)
def _random_valid_T(rng: np.random.Generator) -> np.ndarray:
"""Produce a valid SE(3) 4x4 matrix from a random twist."""
xi = rng.standard_normal(6) * 0.5
pose = gtsam.Pose3.Expmap(xi.astype(np.float64))
return np.ascontiguousarray(pose.matrix(), dtype=np.float64)
def test_ac1_matrix_se3_roundtrip_within_tolerance() -> None:
# Arrange + Act + Assert
for _ in range(20):
T = _random_valid_T(RNG)
pose = matrix_to_se3(T)
recovered = se3_to_matrix(pose)
assert np.allclose(recovered, T, atol=1e-9), (
f"matrix_to_se3 -> se3_to_matrix round-trip diverged for T:\n{T}"
)
def test_ac2_lie_algebra_roundtrip() -> None:
# Arrange + Act + Assert
for _ in range(20):
xi = RNG.standard_normal(6).astype(np.float64)
norm = float(np.linalg.norm(xi))
if norm == 0.0:
continue
xi *= 1.0 / norm # norm ~= 1.0
recovered = log_map(exp_map(xi))
assert np.allclose(recovered, xi, atol=1e-9), (
f"log_map(exp_map(xi)) round-trip diverged for xi={xi}"
)
def test_ac3_near_identity_exp_map_is_stable() -> None:
# Arrange
xi = np.full(6, 1e-12, dtype=np.float64)
# Act
pose = exp_map(xi)
matrix = se3_to_matrix(pose)
# Assert
identity = np.eye(4, dtype=np.float64)
assert np.allclose(matrix, identity, atol=1e-9), (
f"near-identity exp_map must return identity within atol=1e-9; got\n{matrix}"
)
assert not np.any(np.isnan(matrix)), "near-identity exp_map must not produce NaNs"
def test_ac4_strict_orthogonality_rejection() -> None:
# Arrange — drift R^T R away from I by 1e-3 in Frobenius norm
T = _random_valid_T(RNG)
T[:3, :3] += np.eye(3, dtype=np.float64) * 1e-3
# Act + Assert
with pytest.raises(Se3InvalidMatrixError) as excinfo:
matrix_to_se3(T)
msg = str(excinfo.value)
assert "orthogonal" in msg.lower() or "R^T R" in msg, (
"rejection message must explain the orthogonality drift"
)
def test_ac5_mirror_rotation_rejected() -> None:
# Arrange — flip the sign of one column to produce det(R) = -1
T = _random_valid_T(RNG)
T[:3, 0] = -T[:3, 0]
# Act + Assert
with pytest.raises(Se3InvalidMatrixError) as excinfo:
matrix_to_se3(T)
msg = str(excinfo.value)
assert "determinant" in msg.lower() or "negative" in msg.lower()
def test_ac6_bottom_row_guard() -> None:
# Arrange
T = _random_valid_T(RNG)
T[3] = np.array([0.0, 0.0, 0.0, 2.0], dtype=np.float64)
# Act + Assert
with pytest.raises(Se3InvalidMatrixError) as excinfo:
matrix_to_se3(T)
assert "bottom row" in str(excinfo.value).lower()
def test_ac7_float32_dtype_rejected() -> None:
# Arrange
T = _random_valid_T(RNG).astype(np.float32)
# Act + Assert
with pytest.raises(Se3InvalidMatrixError) as excinfo:
matrix_to_se3(T)
assert "dtype" in str(excinfo.value).lower() or "float" in str(excinfo.value).lower()
def test_ac8_determinism_byte_equal_outputs() -> None:
# Arrange
T = _random_valid_T(RNG)
# Act
pose_a = matrix_to_se3(T)
pose_b = matrix_to_se3(T)
matrix_a = se3_to_matrix(pose_a)
matrix_b = se3_to_matrix(pose_b)
# Assert
assert np.array_equal(matrix_a, matrix_b), (
"matrix_to_se3 -> se3_to_matrix must be byte-equal across calls"
)
def test_ac9_no_upward_imports_to_components() -> None:
"""The helper module MUST NOT import from gps_denied_onboard.components.*."""
# Arrange
module_path = (
Path(__file__).resolve().parents[2]
/ "src"
/ "gps_denied_onboard"
/ "helpers"
/ "se3_utils.py"
)
tree = ast.parse(module_path.read_text())
bad_imports: list[str] = []
# Act
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
module = node.module or ""
if module.startswith("gps_denied_onboard.components"):
bad_imports.append(module)
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("gps_denied_onboard.components"):
bad_imports.append(alias.name)
# Assert
assert not bad_imports, (
f"helpers.se3_utils must not import from components.*; found {bad_imports}"
)
def test_is_valid_rotation_predicate() -> None:
# Arrange
R_good = np.eye(3, dtype=np.float64)
R_drift = np.eye(3, dtype=np.float64) + 1e-2 # off-orthogonal
R_mirror = np.diag([-1.0, 1.0, 1.0]).astype(np.float64)
R_f32 = np.eye(3, dtype=np.float32)
# Assert
assert is_valid_rotation(R_good) is True
assert is_valid_rotation(R_drift) is False
assert is_valid_rotation(R_mirror) is False
assert is_valid_rotation(R_f32) is False
assert is_valid_rotation("not an array") is False # type: ignore[arg-type]
def test_adjoint_shape() -> None:
# Arrange
T = _random_valid_T(RNG)
pose = matrix_to_se3(T)
# Act
ad = adjoint(pose)
# Assert
assert ad.shape == (6, 6)
assert ad.dtype == np.float64
def test_se3_is_gtsam_pose3_alias() -> None:
# Assert
assert SE3 is gtsam.Pose3
+214
View File
@@ -0,0 +1,214 @@
"""AC tests for AZ-280: Sha256Sidecar helper.
Verifies the `sha256_sidecar` contract v1.0.0 — atomic write, independent
verification, sidecar format strictness, aggregate determinism, and the
no-upward-imports invariant.
"""
from __future__ import annotations
import ast
import hashlib
from pathlib import Path
import pytest
from gps_denied_onboard.helpers import (
SIDECAR_SUFFIX,
Sha256Sidecar,
Sha256SidecarError,
)
def _payload(seed: int, size: int = 1 << 20) -> bytes:
"""1 MiB random-ish payload derived from a seed for reproducibility."""
import random
rng = random.Random(seed)
return bytes(rng.randint(0, 255) for _ in range(size))
def _sidecar(path: Path) -> Path:
return Path(str(path) + SIDECAR_SUFFIX)
def test_ac1_roundtrip_write_and_verify(tmp_path: Path) -> None:
# Arrange
payload = _payload(seed=1)
target = tmp_path / "artifact.bin"
# Act
written_digest = Sha256Sidecar.write_atomic_and_sidecar(target, payload)
# Assert
assert target.exists()
assert _sidecar(target).exists()
sidecar_text = _sidecar(target).read_text()
expected_digest = hashlib.sha256(payload).hexdigest()
assert sidecar_text == expected_digest
assert written_digest == expected_digest
assert Sha256Sidecar.verify(target) is True
def test_ac2_atomicity_no_partial_file_on_fault(tmp_path: Path, monkeypatch) -> None:
# Arrange
payload = b"hello"
target = tmp_path / "atomic.bin"
# Sabotage `os.replace` via the atomicwrites module so the rename step fails.
import atomicwrites
real_replace = atomicwrites.replace_atomic
def _failing_replace(src: str, dst: str) -> None:
raise OSError("simulated rename failure")
monkeypatch.setattr(atomicwrites, "replace_atomic", _failing_replace)
# Act + Assert — the write should raise; no half file should appear at the target.
with pytest.raises(Sha256SidecarError):
Sha256Sidecar.write_atomic(target, payload)
assert not target.exists(), "no partial file may exist at the target name on fault"
# Also confirm no orphaned `.tmp` siblings remain at the target name.
siblings = [p for p in tmp_path.iterdir() if p.name.startswith("atomic.bin")]
assert all(p == target for p in siblings if p.exists())
# Cleanup
monkeypatch.setattr(atomicwrites, "replace_atomic", real_replace)
def test_ac3_independent_verification_rejects_swapped_payload(tmp_path: Path) -> None:
# Arrange
payload = b"original payload"
target = tmp_path / "swap.bin"
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
# Act — replace the artifact bytes out-of-band without updating the sidecar.
target.write_bytes(b"completely different bytes that are longer")
# Assert
assert Sha256Sidecar.verify(target) is False
def test_ac4_missing_sidecar_raises(tmp_path: Path) -> None:
# Arrange
payload = b"data"
target = tmp_path / "missing_sidecar.bin"
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
_sidecar(target).unlink()
# Act + Assert
with pytest.raises(Sha256SidecarError) as excinfo:
Sha256Sidecar.verify(target)
assert "sidecar" in str(excinfo.value).lower()
def test_ac5_malformed_sidecar_rejected(tmp_path: Path) -> None:
# Arrange
payload = b"data"
target = tmp_path / "bad_sidecar.bin"
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
_sidecar(target).write_text("not a hex digest at all")
# Act + Assert
with pytest.raises(Sha256SidecarError):
Sha256Sidecar.verify(target)
def test_ac6_aggregate_hash_order_deterministic(tmp_path: Path) -> None:
# Arrange
paths = []
for i in range(3):
p = tmp_path / f"file_{i}.bin"
Sha256Sidecar.write_atomic(p, f"payload-{i}".encode())
paths.append(p)
# Act
a = Sha256Sidecar.aggregate_hash(paths)
b = Sha256Sidecar.aggregate_hash(list(reversed(paths)))
c = Sha256Sidecar.aggregate_hash([paths[1], paths[0], paths[2]])
# Assert — same set, three permutations -> identical aggregate
assert a == b == c
def test_ac7_aggregate_hash_missing_path_raises(tmp_path: Path) -> None:
# Arrange
real = tmp_path / "real.bin"
Sha256Sidecar.write_atomic(real, b"data")
ghost = tmp_path / "ghost.bin" # never created
# Act + Assert
with pytest.raises(Sha256SidecarError) as excinfo:
Sha256Sidecar.aggregate_hash([real, ghost])
assert "ghost.bin" in str(excinfo.value)
def test_ac8_sidecar_format_strictness(tmp_path: Path) -> None:
# Arrange
payload = b"hello world"
target = tmp_path / "strict.bin"
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
# Act
raw_bytes = _sidecar(target).read_bytes()
text = _sidecar(target).read_text()
# Assert — exactly 64 hex chars, no newline, no JSON wrapper, no whitespace.
assert len(raw_bytes) == 64, f"sidecar must be 64 bytes; got {len(raw_bytes)}"
assert not raw_bytes.endswith(b"\n"), "sidecar must NOT have a trailing newline"
assert text == text.strip(), "sidecar must contain no whitespace"
int(text, 16) # raises if not pure hex
assert text == hashlib.sha256(payload).hexdigest()
def test_ac9_no_upward_imports_to_components() -> None:
# Arrange
module_path = (
Path(__file__).resolve().parents[2]
/ "src"
/ "gps_denied_onboard"
/ "helpers"
/ "sha256_sidecar.py"
)
tree = ast.parse(module_path.read_text())
bad_imports: list[str] = []
# Act
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
module = node.module or ""
if module.startswith("gps_denied_onboard.components"):
bad_imports.append(module)
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("gps_denied_onboard.components"):
bad_imports.append(alias.name)
# Assert
assert not bad_imports, (
f"helpers.sha256_sidecar must not import from components.*; found {bad_imports}"
)
def test_verify_on_missing_path_returns_false(tmp_path: Path) -> None:
"""Contract: `verify` distinguishes 'missing path' (False) from 'missing sidecar' (raise)."""
# Act + Assert
assert Sha256Sidecar.verify(tmp_path / "never_created.bin") is False
def test_aggregate_includes_all_file_digests(tmp_path: Path) -> None:
"""The aggregate must be sensitive to file content, not only path names."""
# Arrange
a = tmp_path / "a.bin"
b = tmp_path / "b.bin"
Sha256Sidecar.write_atomic(a, b"alpha")
Sha256Sidecar.write_atomic(b, b"beta")
digest_initial = Sha256Sidecar.aggregate_hash([a, b])
# Act — mutate b's content and recompute
Sha256Sidecar.write_atomic(b, b"gamma")
digest_after = Sha256Sidecar.aggregate_hash([a, b])
# Assert
assert digest_initial != digest_after
+10 -9
View File
@@ -1,9 +1,11 @@
"""Structured logging smoke — AZ-263 AC-7."""
"""Structured logging smoke — AZ-263 AC-7 (schema-compliant per AZ-266)."""
import io
import json
import logging
from gps_denied_onboard.logging import get_logger
from gps_denied_onboard.logging.structured import JsonFormatter
def test_get_logger_returns_logger_instance() -> None:
@@ -16,25 +18,24 @@ def test_get_logger_returns_logger_instance() -> None:
def test_log_lines_are_single_json_objects() -> None:
# Arrange
import io
from gps_denied_onboard.logging.structured import _JsonFormatter
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(_JsonFormatter())
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("test.json.unit")
logger.handlers = [handler]
logger.setLevel(logging.DEBUG)
logger.propagate = False
# Act
logger.error("hello world", extra={"event": "smoke", "value": 42})
logger.error(
"hello world",
extra={"kind": "test.smoke", "kv": {"event": "smoke", "value": 42}},
)
# Assert
line = stream.getvalue().strip().splitlines()[-1]
payload = json.loads(line)
assert payload["level"] == "ERROR"
assert payload["msg"] == "hello world"
assert payload["event"] == "smoke"
assert payload["value"] == 42
assert payload["kind"] == "test.smoke"
assert payload["kv"] == {"event": "smoke", "value": 42}