test(ttp): E.2.14b RuleStore conformance — cross-backend + filesystem-specific + database-specific
tests/ttp/store/conftest.py — parametrized rule_store fixture over FilesystemRuleStore (skipped on non-Linux) + DatabaseRuleStore. test_conformance.py — shared assertions (default-state, set_state isolation/round-trip, subscribe_changes per-rule fan-out, expires_at auto-revert, set_state failure semantics) parametrize over both. get_state-default GREEN today on FS (returns RuleState() for empty cache); rest xfail-gated behind E.3.5/E.3.6. test_filesystem.py — inotify mask + canonical kernel values + 9 scratch-filename rejections + 4 valid-filename acceptances + fullmatch anchor + tmp_path construction + CompiledRule frozen property GREEN today; per-save-style + filter-ordering + atomic-swap concurrency xfail-gated. test_database.py — class-level surface (no platform guard, ABC methods concrete, async coroutines) GREEN today; ttp_rule_state write + filesystem→DB sync xfail-gated behind E.3.6.
This commit is contained in:
0
tests/ttp/store/__init__.py
Normal file
0
tests/ttp/store/__init__.py
Normal file
47
tests/ttp/store/conftest.py
Normal file
47
tests/ttp/store/conftest.py
Normal file
@@ -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()
|
||||||
173
tests/ttp/store/test_conformance.py
Normal file
173
tests/ttp/store/test_conformance.py
Normal file
@@ -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")
|
||||||
80
tests/ttp/store/test_database.py
Normal file
80
tests/ttp/store/test_database.py
Normal file
@@ -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")
|
||||||
221
tests/ttp/store/test_filesystem.py
Normal file
221
tests/ttp/store/test_filesystem.py
Normal file
@@ -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 ``<sys/inotify.h>``. 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")
|
||||||
Reference in New Issue
Block a user