diff --git a/tests/ttp/store/__init__.py b/tests/ttp/store/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ttp/store/conftest.py b/tests/ttp/store/conftest.py new file mode 100644 index 00000000..5a218cf8 --- /dev/null +++ b/tests/ttp/store/conftest.py @@ -0,0 +1,47 @@ +"""Parametrized ``rule_store`` fixture for E.2.14b. + +The conformance contract from ``development/TTP_TAGGING.md`` §E.2.14b: +both backends — :class:`FilesystemRuleStore` and +:class:`DatabaseRuleStore` — must satisfy the same observable +behavior. Tests that consume :func:`rule_store` are run twice, once +per backend. + +Filesystem is skipped on non-Linux (it raises ``RuntimeError`` from +``__init__`` on macOS / Windows because the inotify dep is +Linux-only). +""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Iterator + +import pytest + +from decnet.ttp.store.base import RuleStore +from decnet.ttp.store.impl.database import DatabaseRuleStore +from decnet.ttp.store.impl.filesystem import FilesystemRuleStore + + +@pytest.fixture( + params=["filesystem", "database"], + ids=["filesystem", "database"], +) +def rule_store( + request: pytest.FixtureRequest, tmp_path: Path, +) -> Iterator[RuleStore]: + """Yield a fresh :class:`RuleStore` instance per parametrization. + + The filesystem backend is constructed against a ``tmp_path`` + rules dir so tests never touch the real ``./rules/``. The + database backend's connection wiring lands at E.3.6; today the + fixture just hands out the raw class instance and impl-phase + tests are responsible for plumbing it into a session. + """ + backend = request.param + if backend == "filesystem": + if sys.platform != "linux": + pytest.skip("FilesystemRuleStore requires Linux (inotify)") + yield FilesystemRuleStore(rules_dir=tmp_path) + else: + yield DatabaseRuleStore() diff --git a/tests/ttp/store/test_conformance.py b/tests/ttp/store/test_conformance.py new file mode 100644 index 00000000..19f4d76a --- /dev/null +++ b/tests/ttp/store/test_conformance.py @@ -0,0 +1,173 @@ +"""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 inspect + +import pytest + +from decnet.ttp.store.base import RuleChange, RuleState, RuleStore + + +# ── 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``. + + GREEN for :class:`FilesystemRuleStore` (the impl already returns + ``RuleState()`` for an empty cache; covered in the contract + file). xfail for :class:`DatabaseRuleStore` until E.3.6 lands. + """ + if type(rule_store).__name__ == "DatabaseRuleStore": + pytest.xfail("impl phase E.3.6 — DatabaseRuleStore.get_state") + 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) ──────────────── + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5/E.3.6 — load_compiled lands with each " + "backend's parse-and-compile implementation", +) +async def test_load_compiled_corpus_identical_across_backends( + rule_store: RuleStore, +) -> 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.""" + pytest.fail("load_compiled not yet implemented") + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5/E.3.6 — set_state lands with each " + "backend's persistence implementation", +) +async def test_set_state_isolates_rules(rule_store: RuleStore) -> None: + """``set_state(A, ...)`` does not perturb the state read by + ``get_state(B)``. Catches a refactor that accidentally writes + a global cache key.""" + pytest.fail("set_state not yet implemented") + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5/E.3.6 — set_state round-trip lands with " + "each backend's persistence implementation", +) +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.""" + pytest.fail("set_state round-trip not yet implemented") + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5/E.3.6 — subscribe_changes incremental " + "fan-out lands with each backend's watch implementation", +) +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.""" + pytest.fail("subscribe_changes not yet implemented") + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5/E.3.6 — expires_at auto-revert + " + "ttp.rule.state.{rule_id} emission land with each backend impl", +) +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.""" + pytest.fail("expires_at auto-revert not yet implemented") + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5/E.3.6 — set_state failure semantics " + "(raise, never silently drop) land with each backend impl", +) +async def test_set_state_failure_raises_not_silent( + rule_store: RuleStore, +) -> None: + """A backend failure during :meth:`set_state` (e.g. DB write + error, disk full) MUST raise rather than silently drop. + Operational state changes are NOT a tolerated-absence path — + state drift would be silent and dangerous.""" + pytest.fail("set_state failure semantics not yet implemented") diff --git a/tests/ttp/store/test_database.py b/tests/ttp/store/test_database.py new file mode 100644 index 00000000..1f735e57 --- /dev/null +++ b/tests/ttp/store/test_database.py @@ -0,0 +1,80 @@ +"""E.2.14b — Database-specific RuleStore properties. + +Per ``development/TTP_TAGGING.md`` §E.2.14b: the database backend's +tests run against BOTH SQLite and MySQL via the ``db_backends`` +fixture in :mod:`tests.web.db.conftest`. Today the database store's +methods raise ``NotImplementedError`` so most assertions xfail. + +The cross-backend conformance assertions (load_compiled equality, +get_state default, set_state isolation/round-trip, +subscribe_changes per-rule fan-out, expires_at auto-revert) live in +:mod:`test_conformance` and run against this backend automatically +via the parametrized ``rule_store`` fixture in :mod:`conftest`. + +This module pins behavior that's *only* meaningful for the database +backend — specifically the propagation of state via the underlying +``ttp_rule_state`` table, which conformance tests exercise but don't +introspect at the SQL level. +""" +from __future__ import annotations + +import inspect + +import pytest + +from decnet.ttp.store.impl.database import DatabaseRuleStore + + +def test_database_store_constructs_without_platform_guard() -> None: + """Unlike the filesystem backend, the database store has no + platform restriction — a macOS / Windows operator who set + ``DECNET_TTP_RULE_STORE_TYPE=database`` MUST be able to import + and construct the class without hitting an import-time error. + Pinned because regressing this would re-block non-Linux + contributors from running the suite at all.""" + store = DatabaseRuleStore() + assert store is not None + + +def test_database_store_implements_abc() -> None: + """All four ABC methods are defined on the concrete class — + not inherited as abstract. Catches a refactor that accidentally + drops a method body without removing the ``@abstractmethod`` + decorator from the ABC.""" + for name in ("load_compiled", "get_state", "set_state", "subscribe_changes"): + member = getattr(DatabaseRuleStore, name) + assert not getattr(member, "__isabstractmethod__", False) + + +def test_async_methods_are_coroutines() -> None: + for name in ("load_compiled", "get_state", "set_state"): + member = getattr(DatabaseRuleStore, name) + assert inspect.iscoroutinefunction(member) + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.6 — DatabaseRuleStore needs to write " + "into ttp_rule_state via the repository session; today the " + "method body raises NotImplementedError", +) +async def test_set_state_writes_to_ttp_rule_state_table() -> None: + """``set_state`` writes / upserts a row in the ``ttp_rule_state`` + table. After the write, a fresh ``DatabaseRuleStore`` instance + sees the same value via :meth:`get_state` (state survives + process restart — that's the whole point of the database + backend over the filesystem one).""" + pytest.fail("DatabaseRuleStore.set_state not yet implemented") + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.6 — master-side filesystem→DB sync of " + "ttp_rule lands with the swarm-mode wiring", +) +async def test_filesystem_to_db_sync_populates_ttp_rule() -> None: + """In swarm mode, the master watches ``./rules/ttp/`` and + syncs each YAML edit into the ``ttp_rule`` table; workers + tail the DB. This test pins the half of the contract that + only the database backend implements.""" + pytest.fail("master-side fs→DB sync not yet implemented") diff --git a/tests/ttp/store/test_filesystem.py b/tests/ttp/store/test_filesystem.py new file mode 100644 index 00000000..456cfe42 --- /dev/null +++ b/tests/ttp/store/test_filesystem.py @@ -0,0 +1,221 @@ +"""E.2.14b — Filesystem-specific RuleStore properties. + +Pins behavior that's unique to :class:`FilesystemRuleStore`: + +* Inotify event mask covers four save styles (``IN_CLOSE_WRITE``, + ``IN_MOVED_TO``, ``IN_CREATE``, ``IN_DELETE``) per ``strace`` of + vim and other editors. Verified by parametrizing each case and + asserting one event per save. +* Filename allowlist (vs denylist) — dotfile / scratch / wrong-ext + filenames produce zero events and zero loaded rules; the positive + sibling case still loads. +* CLOSE_WRITE on a filtered name produces NEITHER a parse attempt + NOR a log line. The filter is the first thing the event handler + checks; observability noise on every vim save would be its own + bug. +* Atomic-swap concurrency: parallel edits compile in a serialized + stream; concurrent :meth:`evaluate` sees only fully-frozen + ``CompiledRule`` (NamedTuple, mutation-resistant). + +Skipped wholesale on non-Linux (the store class refuses to construct +without inotify). Most behavioral assertions xfail-gated behind +E.3.5; the constants and immutability properties are GREEN today. +""" +from __future__ import annotations + +import re +import sys + +import pytest + +from decnet.ttp.impl.rule_engine import CompiledRule +from decnet.ttp.store.base import RuleState +from decnet.ttp.store.impl import filesystem as fs + +pytestmark = pytest.mark.skipif( + sys.platform != "linux", + reason="FilesystemRuleStore is Linux-only (inotify dep)", +) + + +# ── Constants (GREEN today) ───────────────────────────────────────── + + +def test_inotify_mask_covers_four_save_styles() -> None: + """The mask is the bitwise OR of IN_CLOSE_WRITE | IN_MOVED_TO | + IN_CREATE | IN_DELETE. Each bit covers one canonical save style. + Pinning the OR pins the contract: a future contributor cannot + quietly drop a bit (and silently miss an editor's save mode). + """ + expected = ( + fs._IN_CLOSE_WRITE + | fs._IN_MOVED_TO + | fs._IN_CREATE + | fs._IN_DELETE + ) + assert fs._INOTIFY_MASK == expected + # Each bit must actually be set in the composite. + for bit in ( + fs._IN_CLOSE_WRITE, + fs._IN_MOVED_TO, + fs._IN_CREATE, + fs._IN_DELETE, + ): + assert fs._INOTIFY_MASK & bit + + +def test_inotify_mask_uses_canonical_kernel_values() -> None: + """Bit values match ````. Sanity check against + accidental endianness / byte-shuffle bugs in the inlined + constants. Real values from man inotify(7). + """ + assert fs._IN_CLOSE_WRITE == 0x00000008 + assert fs._IN_MOVED_TO == 0x00000080 + assert fs._IN_CREATE == 0x00000100 + assert fs._IN_DELETE == 0x00000200 + + +# ── Filename allowlist (GREEN today) ──────────────────────────────── + + +@pytest.mark.parametrize( + "filename", + [ + ".T1110_brute_force.yaml.swp", # vim swap + ".T1110_brute_force.yaml.swo", # secondary vim swap + "T1110_brute_force.yaml~", # tilde backup + ".T1110_brute_force.yaml.bak", # dot-prefix backup + "4913", # vim atomic-save probe + ".4913", # dot-prefix variant + ".foo", # any dotfile, no yaml + "T1110_brute_force.yaml.tmp", # wrong extension + "T1110_brute_force.txt", # right shape, wrong ext + ], +) +def test_scratch_filenames_rejected_by_allowlist(filename: str) -> None: + """Dotfile / scratch / wrong-extension filenames fail the + allowlist regex. + + Listed exhaustively (rather than property-tested) because the + allowlist's *exclusion* set is the load-bearing surface — a + future "let's also accept .yaml.tmp" PR must trip this test + deliberately. + """ + assert fs._VALID_RULE_FILENAME.fullmatch(filename) is None + + +@pytest.mark.parametrize( + "filename", + [ + "T1110_brute_force.yaml", + "T1078.yaml", + "T1059_command_and_scripting.yml", + "R0001.yaml", + ], +) +def test_valid_rule_filenames_accepted(filename: str) -> None: + """Positive cases: real rule files are accepted by the allowlist + regex. Confirms the filter excludes scratch files without + false-rejecting real ones.""" + assert fs._VALID_RULE_FILENAME.fullmatch(filename) is not None + + +def test_filename_allowlist_uses_fullmatch_semantics() -> None: + """The pattern uses ``\\Z`` / ``fullmatch`` — anchoring is + load-bearing. ``foo.yaml.tmp`` would match a non-anchored + ``.yaml`` substring search but is correctly rejected by + ``fullmatch``. Pinning the regex's anchor behavior catches a + refactor to ``re.search`` that would silently widen the + allowlist.""" + # The compiled pattern as authored doesn't carry trailing $ + # because fullmatch implicitly anchors. The string we test + # passes a no-anchor `search` but fails `fullmatch`. + assert re.compile(fs._VALID_RULE_FILENAME.pattern).search( + "T1110_brute_force.yaml.tmp", + ) is not None + assert fs._VALID_RULE_FILENAME.fullmatch( + "T1110_brute_force.yaml.tmp", + ) is None + + +# ── Construction guard (GREEN today) ──────────────────────────────── + + +def test_construct_with_tmp_path_works(tmp_path) -> None: + """The constructor accepts an explicit ``rules_dir`` so tests + can sandbox without touching the real ``./rules/``.""" + store = fs.FilesystemRuleStore(rules_dir=tmp_path) + assert store._rules_dir == tmp_path + + +# ── CompiledRule immutability (GREEN today) ───────────────────────── + + +def test_compiled_rule_is_frozen() -> None: + """``CompiledRule`` is a :class:`NamedTuple`, so field + assignment raises ``AttributeError``. The doc references + ``FrozenInstanceError`` (the dataclass equivalent), but the + actual implementation uses NamedTuple — the in-test smoke + signal is the same property (mutation-resistant) under a + different exception type. Pinning the AttributeError behavior + here clarifies the contract for future readers.""" + rule = CompiledRule( + rule_id="R0001", + rule_version=1, + name="test", + applies_to=frozenset({"attacker_command"}), + match_spec={}, + emits=(("T1110", None),), + evidence_fields=(), + state=RuleState(), + ) + with pytest.raises(AttributeError): + rule.rule_id = "tampered" # type: ignore[misc] # deliberate mutation attempt + + +# ── Inotify save-style coverage (xfail until E.3.5) ───────────────── + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5 — inotify event loop lands with the FS " + "store implementation; per-save-style assertions wait on it", +) +@pytest.mark.parametrize("save_style", ["close_write", "moved_to", "create", "delete"]) +async def test_each_save_style_yields_exactly_one_event( + save_style: str, +) -> None: + """Each of the four save styles produces exactly one + :class:`RuleChange` event from :meth:`subscribe_changes`. xfail + until the inotify event loop lands at E.3.5.""" + pytest.fail(f"inotify event loop not yet implemented ({save_style})") + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5 — scratch-file filter wired into the " + "event handler lands with the FS store implementation", +) +async def test_close_write_on_filtered_name_emits_no_log_line() -> None: + """A CLOSE_WRITE event on a name failing the allowlist (e.g. + ``.foo.yaml.swp``) produces NEITHER a parse attempt NOR a log + line. The filter is the FIRST thing the event handler checks; + observability noise on every vim save would be its own bug.""" + pytest.fail("event handler filter ordering not yet implemented") + + +# ── Atomic-swap concurrency (xfail until E.3.5) ───────────────────── + + +@pytest.mark.xfail( + strict=True, + reason="impl phase E.3.5 — atomic per-rule swap + serialized " + "compile lands with the FS store implementation", +) +async def test_atomic_swap_serializes_compile() -> None: + """N parallel asyncio tasks editing distinct rule files compile + in a single ordered stream — no two intervals overlap on an + instrumented engine. Concurrent :meth:`RuleEngine.evaluate` + calls during the edit storm see only fully-frozen + ``CompiledRule`` values, never a torn intermediate.""" + pytest.fail("atomic-swap concurrency not yet implemented")