"""E.2.14b — Cross-backend conformance for :class:`RuleStore`. Both :class:`FilesystemRuleStore` and :class:`DatabaseRuleStore` must satisfy the same observable contract. The :func:`rule_store` fixture in :mod:`conftest` parametrizes every assertion in this module over both backends. Per ``development/TTP_TAGGING.md`` §E.2.14b: * :meth:`load_compiled` over a known YAML corpus returns the same ``CompiledRule`` set from both backends (modulo state defaulting to enabled when no state row exists). * :meth:`get_state` for an unknown ``rule_id`` returns the default ``RuleState(state="enabled", ...)`` — never raise, never return ``None``. * :meth:`set_state` on one ``rule_id`` does not affect any other. * :meth:`set_state` followed by :meth:`get_state` round-trips faithfully. * :meth:`subscribe_changes` yields ONE :class:`RuleChange` per per-rule edit (5-rule edit → 5 events, never one batch of 5). * ``expires_at`` in the past → :meth:`get_state` returns the default and emits a ``ttp.rule.state.{rule_id}`` auto-revert event. * :meth:`set_state` failure (DB write error) raises rather than silently dropping — operational state changes are not a tolerated-absence path. Filesystem-specific properties (inotify mask, dotfile filter, atomic-swap concurrency) live in :mod:`test_filesystem`. """ from __future__ import annotations import asyncio import inspect from datetime import datetime, timedelta, timezone from pathlib import Path import pytest from decnet.ttp.impl.rule_engine import CompiledRule from decnet.ttp.store.base import RuleChange, RuleState, RuleStore from .conftest import seed_rule _RULE_YAML = """\ rule_id: {rule_id} rule_version: 1 name: test rule applies_to: [command] match: pattern: 'hydra' emits: - tactic: TA0006 technique_id: T1110 confidence: 0.85 """ # ── Surface (GREEN today) ─────────────────────────────────────────── def test_store_implements_required_methods(rule_store: RuleStore) -> None: """Every backend implements all four ABC methods. Catches a refactor that accidentally drops a method body.""" for name in ("load_compiled", "get_state", "set_state", "subscribe_changes"): assert hasattr(rule_store, name) def test_async_methods_are_coroutines() -> None: """The three ``async def`` methods on the ABC are coroutine functions; ``subscribe_changes`` is a regular ``def`` returning an async iterator (per the doc's signature).""" for name in ("load_compiled", "get_state", "set_state"): member = getattr(RuleStore, name) assert inspect.iscoroutinefunction(member), ( f"RuleStore.{name} must be `async def`" ) def test_rule_change_namedtuple_shape() -> None: """:class:`RuleChange` carries ``change_kind``, ``rule_id``, ``new_value`` — pinned so a future "improvement" that adds fields trips downstream consumers (the bus republisher, the engine's atomic swap path) deliberately rather than silently.""" assert RuleChange._fields == ("change_kind", "rule_id", "new_value") # ── Default state behavior ────────────────────────────────────────── async def test_get_state_unknown_returns_default(rule_store: RuleStore) -> None: """``get_state`` for a never-set ``rule_id`` returns the default ``RuleState`` — never raises, never returns ``None``.""" state = await rule_store.get_state("R0001_unknown_rule") assert state == RuleState() assert state.state == "enabled" # ── Behavioral conformance (xfail until E.3.5/E.3.6) ──────────────── async def test_load_compiled_corpus_identical_across_backends( rule_store: RuleStore, tmp_path: Path, ) -> None: """Both backends, given the same YAML corpus, return the same set of ``CompiledRule`` (modulo state defaulting). The doc's cross-backend property requires running the same fixture against both — pinned here as a single test that the parametrize fans out over both backends.""" await seed_rule(rule_store, "R0001", _RULE_YAML.format(rule_id="R0001")) await seed_rule(rule_store, "R0002", _RULE_YAML.format(rule_id="R0002")) compiled = await rule_store.load_compiled() assert {c.rule_id for c in compiled} == {"R0001", "R0002"} for c in compiled: assert isinstance(c, CompiledRule) assert c.state == RuleState() assert c.applies_to == frozenset({"command"}) assert c.emits == (("T1110", None, "TA0006", 0.85),) async def test_set_state_isolates_rules(rule_store: RuleStore) -> None: """``set_state(A, ...)`` does not perturb the state read by ``get_state(B)``.""" await rule_store.set_state( "R0001", RuleState(state="disabled", reason="A"), set_by="op", ) other = await rule_store.get_state("R0002") assert other == RuleState() # B untouched async def test_set_state_then_get_state_round_trips( rule_store: RuleStore, ) -> None: """``set_state`` followed by ``get_state`` returns the value that was set. No translation, no field drop.""" new_state = RuleState( state="clipped", confidence_max=0.5, reason="probation", ) await rule_store.set_state("R0001", new_state, set_by="op") got = await rule_store.get_state("R0001") assert got.state == "clipped" assert got.confidence_max == 0.5 assert got.reason == "probation" assert got.set_by == "op" assert got.set_at is not None async def test_subscribe_changes_per_rule_not_batched( rule_store: RuleStore, ) -> None: """A 5-rule edit produces 5 :class:`RuleChange` events from :meth:`subscribe_changes`, never a single event carrying 5 entries. The bus per-rule fan-out (``ttp.rule.reloaded.{rule_id}``) inherits its granularity from this iterator.""" sub = rule_store.subscribe_changes() for i in range(5): await rule_store.set_state( f"R000{i}", RuleState(state="disabled"), set_by="op", ) seen: list[RuleChange] = [] for _ in range(5): seen.append(await asyncio.wait_for(sub.__anext__(), timeout=2.0)) rule_ids = {ch.rule_id for ch in seen} assert rule_ids == {f"R000{i}" for i in range(5)} for ch in seen: assert ch.change_kind == "state" assert isinstance(ch.new_value, RuleState) async def test_expired_state_reverts_to_default_and_emits( rule_store: RuleStore, ) -> None: """A ``RuleState`` with ``expires_at`` in the past returns the default from :meth:`get_state` AND emits a ``ttp.rule.state.{rule_id}`` auto-revert event.""" past = datetime.now(tz=timezone.utc) - timedelta(seconds=5) sub = rule_store.subscribe_changes() await rule_store.set_state( "R0001", RuleState(state="disabled", expires_at=past), set_by="op", ) # Drain the set_state event we just produced. await asyncio.wait_for(sub.__anext__(), timeout=2.0) state = await rule_store.get_state("R0001") assert state == RuleState() revert = await asyncio.wait_for(sub.__anext__(), timeout=2.0) assert revert.change_kind == "state" assert revert.rule_id == "R0001" assert revert.new_value == RuleState() async def test_set_state_failure_raises_not_silent( rule_store: RuleStore, ) -> None: """A backend failure during :meth:`set_state` (e.g. queue death) MUST raise rather than silently drop. Operational state changes are NOT a tolerated-absence path — state drift would be silent and dangerous.""" class _BoomQueue: async def put(self, _item: object) -> None: raise RuntimeError("simulated backend failure") # Inject a poisoned subscriber so the publish path raises. if not hasattr(rule_store, "_subscribers"): # pragma: no cover pytest.skip("backend has no subscriber fan-out hook") rule_store._subscribers.append(_BoomQueue()) with pytest.raises(RuntimeError, match="simulated backend failure"): await rule_store.set_state( "R0001", RuleState(state="disabled"), set_by="op", )