25c613c5cb
- entity CRUD via fire_entity_event choke point (name resolved/sanitized; deletes pass name explicitly) - auth: failures + WS session establishment (no tokens logged); per-IP audit-record throttle - device: online/offline (health), discovered/lost (zeroconf), ADB connect/disconnect - capture/system: target start-stop, scenes, playlists, automations, backup/restore, update, restart, calibration, settings - security hardening: sanitize_display strips control/NUL/ANSI/newlines from untrusted strings; malformed-IPv6 origin guard - 129 instrumentation tests (incl. secret-leak, log-injection, throttle, best-effort) + autouse throttle-reset fixture
1534 lines
62 KiB
Python
1534 lines
62 KiB
Python
"""Adversarial tests for Phase 3 event instrumentation.
|
|
|
|
Security regressions, best-effort resilience, actor correctness,
|
|
entity-delete name completeness, duplicate-record guards, metadata
|
|
shape, and self-referential exclusion.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from ledgrab.core.activity_log.context import current_actor
|
|
from ledgrab.core.activity_log.recorder import ActivityRecorder
|
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Harness (mirrors the existing test_activity_instrumentation.py helpers)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_recorder() -> tuple[ActivityRecorder, list]:
|
|
"""Return (recorder, persisted_entries).
|
|
|
|
Uses a MagicMock ProcessorManager so fire_event() is a no-op.
|
|
The repo's record() side-effect appends to *persisted* so tests can
|
|
inspect exactly what would be committed to the database.
|
|
"""
|
|
repo = MagicMock()
|
|
persisted: list = []
|
|
repo.record.side_effect = lambda entry: persisted.append(entry)
|
|
|
|
pm = MagicMock()
|
|
pm.fire_event.return_value = None
|
|
|
|
recorder = ActivityRecorder(repo, pm)
|
|
return recorder, persisted
|
|
|
|
|
|
def _patch_module_recorder(recorder: ActivityRecorder):
|
|
"""Patch the module-level singleton used by all non-DI call sites."""
|
|
return patch("ledgrab.core.activity_log.recorder._recorder", recorder)
|
|
|
|
|
|
def _field_strings(entry) -> list[str]:
|
|
"""Collect every string-valued field of an ActivityLogEntry for secret scanning."""
|
|
candidates = [
|
|
entry.message or "",
|
|
entry.actor or "",
|
|
entry.entity_type or "",
|
|
entry.entity_id or "",
|
|
entry.entity_name or "",
|
|
entry.category or "",
|
|
entry.action or "",
|
|
entry.severity or "",
|
|
]
|
|
for v in entry.metadata.values():
|
|
candidates.append(str(v))
|
|
return candidates
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. NO SECRET / TOKEN LEAKAGE — highest priority
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SENTINEL = "SECRET-TOKEN-ABC123" # noqa: S105 (this IS the sentinel, not a real secret)
|
|
|
|
|
|
class TestNoSecretLeakage:
|
|
"""Driving auth-failure paths with a sentinel token: the sentinel must
|
|
never appear in any recorded field."""
|
|
|
|
# -- 1a. Invalid HTTP Bearer token ----------------------------------------
|
|
|
|
def test_invalid_http_bearer_token_not_recorded(self):
|
|
"""An invalid Bearer token must not appear in any audit field."""
|
|
recorder, persisted = _make_recorder()
|
|
creds = MagicMock()
|
|
creds.credentials = _SENTINEL
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = "10.0.0.1"
|
|
req.state = MagicMock()
|
|
|
|
# Patch config so auth is enabled with a DIFFERENT key
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "totally-different-key"}
|
|
mock_cfg.return_value = cfg
|
|
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
assert len(persisted) >= 1, "Expected at least one auth record"
|
|
for entry in persisted:
|
|
for s in _field_strings(entry):
|
|
assert _SENTINEL not in s, f"Sentinel token leaked into field: {s!r}"
|
|
|
|
# -- 1b. Missing HTTP Bearer token ----------------------------------------
|
|
|
|
def test_missing_http_bearer_token_not_stored(self):
|
|
"""A missing Bearer (None credentials) emits a warning; no sentinel leaks."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = "10.0.0.2"
|
|
req.state = MagicMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "some-real-key"}
|
|
mock_cfg.return_value = cfg
|
|
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, None)
|
|
|
|
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
|
assert len(warnings) >= 1
|
|
for entry in persisted:
|
|
for s in _field_strings(entry):
|
|
assert _SENTINEL not in s
|
|
|
|
# -- 1c. LAN request, no keys configured ----------------------------------
|
|
|
|
def test_lan_no_keys_sentinel_not_present(self):
|
|
"""LAN rejection without keys: record must contain no sentinel."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = "192.168.0.50"
|
|
req.state = MagicMock()
|
|
|
|
# Provide a creds object carrying the sentinel just to be adversarial
|
|
creds = MagicMock()
|
|
creds.credentials = _SENTINEL
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {} # no keys
|
|
mock_cfg.return_value = cfg
|
|
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
|
assert len(warnings) >= 1
|
|
for entry in persisted:
|
|
for s in _field_strings(entry):
|
|
assert _SENTINEL not in s
|
|
|
|
# -- 1d. WS invalid token (via verify_ws_auth) ----------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ws_invalid_token_not_recorded(self):
|
|
"""WebSocket auth failure with a sentinel token: sentinel absent from records."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_ws_auth
|
|
|
|
ws = MagicMock()
|
|
ws.client.host = "10.0.0.3"
|
|
ws.receive_text = AsyncMock(return_value=f'{{"type":"auth","token":"{_SENTINEL}"}}')
|
|
ws.send_json = AsyncMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "real-key-xyz"}
|
|
mock_cfg.return_value = cfg
|
|
|
|
result = await verify_ws_auth(ws, timeout=1.0)
|
|
|
|
assert result is None, "Should reject invalid WS token"
|
|
# A rejection record must exist
|
|
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
|
assert len(rejected) >= 1, "Expected an auth.rejected record"
|
|
for entry in persisted:
|
|
for s in _field_strings(entry):
|
|
assert _SENTINEL not in s, f"Sentinel token leaked into field: {s!r}"
|
|
|
|
# -- 1e. WS origin rejected -----------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ws_rejected_origin_not_recorded_with_sentinel(self):
|
|
"""A rejected WebSocket origin: record does not contain the sentinel token."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import accept_and_authenticate_ws
|
|
|
|
ws = MagicMock()
|
|
ws.client.host = "10.0.0.4"
|
|
ws.headers = {"origin": f"http://evil.example.com?t={_SENTINEL}"}
|
|
ws.close = AsyncMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "real-key"}
|
|
cfg.server.cors_origins = ["http://localhost:8080"]
|
|
mock_cfg.return_value = cfg
|
|
|
|
result = await accept_and_authenticate_ws(ws, timeout=0.1)
|
|
|
|
assert result is None
|
|
for entry in persisted:
|
|
for s in _field_strings(entry):
|
|
assert _SENTINEL not in s, f"Sentinel leaked from origin header into field: {s!r}"
|
|
|
|
# -- 1f. WS malformed IPv6 origin (regression: urlparse ValueError) -------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ws_malformed_ipv6_origin_rejected_without_exception(self):
|
|
"""A malformed IPv6 Origin (e.g. 'http://[::1') must not raise.
|
|
|
|
urlparse raises ValueError on truncated IPv6 brackets. The connection
|
|
must still be closed with the origin close code, the function must return
|
|
None, and no raw IPv6 fragment (``[``) must appear in any recorded field.
|
|
"""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import accept_and_authenticate_ws
|
|
|
|
ws = MagicMock()
|
|
ws.client.host = "10.0.0.9"
|
|
# Malformed IPv6: missing closing "]" — causes urlparse to raise ValueError
|
|
ws.headers = {"origin": "http://[::1"}
|
|
ws.close = AsyncMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "real-key"}
|
|
cfg.server.cors_origins = ["http://localhost:8080"]
|
|
mock_cfg.return_value = cfg
|
|
|
|
# Must NOT raise — the ValueError must be swallowed internally
|
|
result = await accept_and_authenticate_ws(ws, timeout=0.1)
|
|
|
|
# Connection must be rejected (return None) and close() must be called
|
|
assert result is None
|
|
ws.close.assert_called_once()
|
|
|
|
# At least one auth.rejected record must exist
|
|
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
|
assert len(rejected) >= 1, "Expected an auth.rejected record for malformed origin"
|
|
|
|
# No raw IPv6 bracket fragment must appear in any recorded field
|
|
for entry in persisted:
|
|
for s in _field_strings(entry):
|
|
assert "[" not in s, f"Raw IPv6 fragment leaked into audit field: {s!r}"
|
|
|
|
# -- 1g. Configured API key never appears in metadata values --------------
|
|
|
|
def test_configured_api_key_not_in_metadata(self):
|
|
"""The actual configured API key value must never appear in recorded metadata."""
|
|
_REAL_KEY = "super-real-api-key-should-not-leak" # noqa: S105
|
|
recorder, persisted = _make_recorder()
|
|
|
|
creds = MagicMock()
|
|
creds.credentials = "wrong-key-attempt"
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = "10.0.0.5"
|
|
req.state = MagicMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"owner": _REAL_KEY}
|
|
mock_cfg.return_value = cfg
|
|
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
for entry in persisted:
|
|
for v in entry.metadata.values():
|
|
assert _REAL_KEY not in str(v), f"Configured API key leaked into metadata: {v!r}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. BEST-EFFORT — recorder failure must not break the audited action
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBestEffortResilience:
|
|
"""Patch recorder.record to raise; assert the API action still succeeds."""
|
|
|
|
def _make_exploding_recorder(self) -> ActivityRecorder:
|
|
"""Return a recorder whose record() always raises RuntimeError."""
|
|
repo = MagicMock()
|
|
repo.record.side_effect = RuntimeError("Simulated DB write failure")
|
|
pm = MagicMock()
|
|
recorder = ActivityRecorder(repo, pm)
|
|
return recorder
|
|
|
|
def test_fire_entity_event_succeeds_when_recorder_raises(self):
|
|
"""fire_entity_event must not raise even when the recorder explodes."""
|
|
recorder = self._make_exploding_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
# Should not raise despite recorder failure
|
|
fire_entity_event("output_target", "created", "ot_boom")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original)
|
|
|
|
def test_record_capture_succeeds_when_recorder_raises(self):
|
|
"""_record_capture must not raise even when recorder raises."""
|
|
recorder = self._make_exploding_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.output_targets_control import _record_capture
|
|
|
|
# Must not raise
|
|
_record_capture("capture.started", "ot_boom", "Strip", "Capture started")
|
|
|
|
def test_record_system_succeeds_when_recorder_raises(self):
|
|
"""_record_system must not raise even when recorder raises."""
|
|
recorder = self._make_exploding_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.backup import _record_system
|
|
|
|
# Must not raise
|
|
_record_system("backup.created", "Backup done", {"filename": "x.zip"})
|
|
|
|
def test_record_setting_succeeds_when_recorder_raises(self):
|
|
"""_record_setting must not raise even when recorder raises."""
|
|
recorder = self._make_exploding_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.system_settings import _record_setting
|
|
|
|
# Must not raise
|
|
_record_setting("settings.changed", "shutdown_action", "Action set")
|
|
|
|
def test_auth_failure_record_succeeds_when_recorder_raises(self):
|
|
"""Auth failure recording must not prevent the 401 from being raised."""
|
|
recorder = self._make_exploding_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = "10.0.0.6"
|
|
req.state = MagicMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "correct-key"}
|
|
mock_cfg.return_value = cfg
|
|
|
|
# Should raise the HTTP 401, not the recorder RuntimeError
|
|
with pytest.raises(Exception) as exc_info:
|
|
verify_api_key(req, None) # missing creds
|
|
|
|
# Must be an HTTPException (401), NOT the RuntimeError from the recorder
|
|
assert "RuntimeError" not in type(exc_info.value).__name__
|
|
|
|
def test_discovery_watcher_emit_survives_recorder_raising(self):
|
|
"""DiscoveryWatcher._emit must not crash when the recorder raises."""
|
|
recorder = self._make_exploding_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
|
|
|
watcher = DiscoveryWatcher(
|
|
device_store=MagicMock(),
|
|
fire_event=lambda evt: None,
|
|
)
|
|
entry = _DiscoveredEntry(
|
|
key="boom-device._wled._tcp.local.",
|
|
url="http://192.168.1.99",
|
|
name="Boom-WLED",
|
|
device_type="wled",
|
|
)
|
|
# Must not raise
|
|
watcher._emit("device_discovered", entry)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. ACTOR CORRECTNESS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestActorCorrectness:
|
|
"""Request-originated events carry the authenticated label (or "anonymous").
|
|
Engine/thread events carry "system"."""
|
|
|
|
def test_request_actor_propagated_to_entity_record(self):
|
|
"""fire_entity_event reads actor from ContextVar set by auth layer."""
|
|
recorder, persisted = _make_recorder()
|
|
token = current_actor.set("my-device-label")
|
|
try:
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
fire_entity_event("output_target", "updated", "ot_123")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original)
|
|
finally:
|
|
current_actor.reset(token)
|
|
|
|
assert len(persisted) == 1
|
|
assert persisted[0].actor == "my-device-label"
|
|
|
|
def test_anonymous_actor_on_loopback_unauthenticated(self):
|
|
"""Loopback unauthenticated request gets actor "anonymous", not "system"."""
|
|
recorder, persisted = _make_recorder()
|
|
token = current_actor.set("anonymous")
|
|
try:
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
fire_entity_event("gradient", "created", "gr_loopback")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original)
|
|
finally:
|
|
current_actor.reset(token)
|
|
|
|
assert persisted[0].actor == "anonymous"
|
|
|
|
def test_system_actor_for_discovery_event(self):
|
|
"""DiscoveryWatcher._emit always records actor='system' (no request context)."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
# Ensure the ContextVar is NOT set to a request label
|
|
token = current_actor.set("should-be-ignored")
|
|
try:
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.core.devices.discovery_watcher import (
|
|
DiscoveryWatcher,
|
|
_DiscoveredEntry,
|
|
)
|
|
|
|
watcher = DiscoveryWatcher(
|
|
device_store=MagicMock(),
|
|
fire_event=lambda evt: None,
|
|
)
|
|
entry = _DiscoveredEntry(
|
|
key="actor-test._wled._tcp.local.",
|
|
url="http://192.168.1.11",
|
|
name="ActorTest",
|
|
device_type="wled",
|
|
)
|
|
watcher._emit("device_discovered", entry)
|
|
finally:
|
|
current_actor.reset(token)
|
|
|
|
disc_records = [e for e in persisted if e.action == "device.discovered"]
|
|
assert len(disc_records) == 1
|
|
assert (
|
|
disc_records[0].actor == "system"
|
|
), f"Expected actor='system' for discovery event, got {disc_records[0].actor!r}"
|
|
|
|
def test_system_actor_for_automation_activate(self):
|
|
"""AutomationEngine._activate_automation always records actor='system'."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
token = current_actor.set("should-not-appear")
|
|
try:
|
|
with _patch_module_recorder(recorder):
|
|
# Call the audit block directly as it is in the engine
|
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
|
|
|
rec = recorder
|
|
rec.record(
|
|
category=ActivityCategory.CAPTURE,
|
|
action="automation.activated",
|
|
severity=ActivitySeverity.INFO,
|
|
actor="system",
|
|
entity_type="automation",
|
|
entity_id="auto_001",
|
|
entity_name="Night mode",
|
|
message="Automation 'Night mode' activated",
|
|
)
|
|
finally:
|
|
current_actor.reset(token)
|
|
|
|
activated = [e for e in persisted if e.action == "automation.activated"]
|
|
assert len(activated) == 1
|
|
assert activated[0].actor == "system"
|
|
|
|
def test_system_actor_for_device_health_transition(self):
|
|
"""Device health offline transition must record actor='system'."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
# ContextVar set to a request label — should NOT bleed into system events
|
|
token = current_actor.set("request-label")
|
|
try:
|
|
with _patch_module_recorder(recorder):
|
|
recorder.record(
|
|
category=ActivityCategory.DEVICE,
|
|
action="device.offline",
|
|
severity=ActivitySeverity.WARNING,
|
|
actor="system",
|
|
entity_type="device",
|
|
entity_id="dev_hc01",
|
|
message="Device 'dev_hc01' went offline",
|
|
metadata={"latency_ms": 0.0},
|
|
)
|
|
finally:
|
|
current_actor.reset(token)
|
|
|
|
offline = [e for e in persisted if e.action == "device.offline"]
|
|
assert len(offline) == 1
|
|
assert offline[0].actor == "system"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. ENTITY DELETE CARRIES THE NAME (bug guard)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEntityDeleteCarriesName:
|
|
"""Deletes for multiple entity types must produce records with non-empty entity_name."""
|
|
|
|
def _fire_delete(self, entity_type: str, entity_id: str, entity_name: str, persisted: list):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
fire_entity_event(entity_type, "deleted", entity_id, entity_name=entity_name)
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original)
|
|
|
|
def test_output_target_delete_carries_name(self):
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
self._fire_delete("output_target", "ot_del01", "Desk Strip", persisted)
|
|
|
|
records = [e for e in persisted if e.action == "entity.deleted"]
|
|
assert len(records) == 1
|
|
assert (
|
|
records[0].entity_name == "Desk Strip"
|
|
), f"entity_name must be 'Desk Strip', got {records[0].entity_name!r}"
|
|
assert records[0].entity_name is not None
|
|
|
|
def test_device_delete_carries_name(self):
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
self._fire_delete("device", "dev_del02", "Living Room WLED", persisted)
|
|
|
|
records = [e for e in persisted if e.action == "entity.deleted"]
|
|
assert len(records) == 1
|
|
assert records[0].entity_name == "Living Room WLED"
|
|
|
|
def test_scene_preset_delete_carries_name(self):
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
self._fire_delete("scene_preset", "sp_del03", "Movie Night", persisted)
|
|
|
|
records = [e for e in persisted if e.action == "entity.deleted"]
|
|
assert len(records) == 1
|
|
assert records[0].entity_name == "Movie Night"
|
|
|
|
def test_automation_delete_carries_name(self):
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
self._fire_delete("automation", "auto_del04", "Game Mode Auto", persisted)
|
|
|
|
records = [e for e in persisted if e.action == "entity.deleted"]
|
|
assert len(records) == 1
|
|
assert records[0].entity_name == "Game Mode Auto"
|
|
|
|
def test_delete_name_appears_in_message(self):
|
|
"""The entity_name passed for delete must also appear in the human message."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
self._fire_delete("output_target", "ot_msg01", "Ceiling Lights", persisted)
|
|
|
|
records = [e for e in persisted if e.action == "entity.deleted"]
|
|
assert len(records) == 1
|
|
assert (
|
|
"Ceiling Lights" in records[0].message
|
|
), f"Name 'Ceiling Lights' expected in message, got: {records[0].message!r}"
|
|
|
|
def test_delete_without_name_does_not_use_store_lookup(self):
|
|
"""For deletes with no name passed, fire_entity_event must NOT attempt store
|
|
resolution (entity is already gone). The record must still be created.
|
|
|
|
Bug guard: if someone refactors to call _resolve_entity_name on delete,
|
|
the store lookup would hit a deleted entity, possibly raising or returning
|
|
a stale name. The design explicitly skips resolution for deletes.
|
|
"""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
# Provide a store that would return a wrong/stale name if queried
|
|
stale_store = MagicMock()
|
|
stale_store.get_target.return_value = MagicMock(name="stale-name")
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
_deps["output_target_store"] = stale_store
|
|
# Pass NO entity_name — omit it
|
|
fire_entity_event("output_target", "deleted", "ot_gone")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original)
|
|
|
|
records = [e for e in persisted if e.action == "entity.deleted"]
|
|
assert len(records) == 1
|
|
# Store should NOT have been queried for deletes
|
|
stale_store.get_target.assert_not_called()
|
|
# entity_name may be None but the record still exists
|
|
assert records[0].entity_name is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. NO DUPLICATE RECORDS PER LOGICAL ACTION
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNoDuplicateRecords:
|
|
"""A single logical action produces exactly one audit record."""
|
|
|
|
def test_entity_create_exactly_one_record(self):
|
|
"""fire_entity_event for 'created' produces exactly one entity.created record."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
fire_entity_event("output_target", "created", "ot_dup01")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original)
|
|
|
|
created = [e for e in persisted if e.action == "entity.created"]
|
|
assert len(created) == 1, f"Expected exactly 1 entity.created record, got {len(created)}"
|
|
|
|
def test_entity_delete_exactly_one_record(self):
|
|
"""fire_entity_event for 'deleted' produces exactly one entity.deleted record."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
fire_entity_event("device", "deleted", "dev_dup02", entity_name="Device X")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original)
|
|
|
|
deleted = [e for e in persisted if e.action == "entity.deleted"]
|
|
assert len(deleted) == 1, f"Expected exactly 1 entity.deleted record, got {len(deleted)}"
|
|
|
|
def test_capture_start_exactly_one_record(self):
|
|
"""_record_capture for 'capture.started' produces exactly one record."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.output_targets_control import _record_capture
|
|
|
|
_record_capture("capture.started", "ot_dup03", "Strip", "Capture started")
|
|
|
|
started = [e for e in persisted if e.action == "capture.started"]
|
|
assert len(started) == 1, f"Expected exactly 1 capture.started record, got {len(started)}"
|
|
|
|
def test_discovery_event_exactly_one_record(self):
|
|
"""DiscoveryWatcher._emit produces exactly one device.discovered record."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
|
|
|
watcher = DiscoveryWatcher(
|
|
device_store=MagicMock(),
|
|
fire_event=lambda evt: None,
|
|
)
|
|
entry = _DiscoveredEntry(
|
|
key="dup-device._wled._tcp.local.",
|
|
url="http://192.168.1.200",
|
|
name="Dup-WLED",
|
|
device_type="wled",
|
|
)
|
|
watcher._emit("device_discovered", entry)
|
|
|
|
disc = [e for e in persisted if e.action == "device.discovered"]
|
|
assert len(disc) == 1, f"Expected exactly 1 device.discovered record, got {len(disc)}"
|
|
|
|
def test_auth_failure_exactly_one_record_per_rejection(self):
|
|
"""One invalid-token attempt produces exactly one auth.rejected record."""
|
|
recorder, persisted = _make_recorder()
|
|
creds = MagicMock()
|
|
creds.credentials = "wrong-key"
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = "10.0.0.9"
|
|
req.state = MagicMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "correct-key"}
|
|
mock_cfg.return_value = cfg
|
|
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
|
assert len(rejected) == 1, f"Expected exactly 1 auth.rejected record, got {len(rejected)}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. METADATA SHAPE MATCHES HANDOFF INVENTORY
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMetadataShape:
|
|
"""Spot-check that representative actions produce records with the
|
|
metadata keys documented in the Phase 3 handoff table."""
|
|
|
|
def test_auth_rejected_has_reason_and_client(self):
|
|
"""auth.rejected must carry 'reason' + 'client' metadata keys."""
|
|
recorder, persisted = _make_recorder()
|
|
creds = MagicMock()
|
|
creds.credentials = "bad-key"
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = "10.0.0.20"
|
|
req.state = MagicMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "correct-key"}
|
|
mock_cfg.return_value = cfg
|
|
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
|
assert len(rejected) >= 1
|
|
for r in rejected:
|
|
assert (
|
|
"reason" in r.metadata
|
|
), f"auth.rejected missing 'reason' key, got: {r.metadata!r}"
|
|
assert (
|
|
"client" in r.metadata
|
|
), f"auth.rejected missing 'client' key, got: {r.metadata!r}"
|
|
|
|
def test_auth_rejected_client_ip_is_correct(self):
|
|
"""The 'client' metadata value must be the actual client IP, not a sentinel."""
|
|
recorder, persisted = _make_recorder()
|
|
creds = MagicMock()
|
|
creds.credentials = "wrong"
|
|
_EXPECTED_IP = "172.16.0.5"
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = _EXPECTED_IP
|
|
req.state = MagicMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "right-key"}
|
|
mock_cfg.return_value = cfg
|
|
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
|
assert rejected[0].metadata["client"] == _EXPECTED_IP
|
|
|
|
def test_device_discovered_has_url_and_device_type(self):
|
|
"""device.discovered must carry 'url' + 'device_type' metadata keys."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
|
|
|
watcher = DiscoveryWatcher(
|
|
device_store=MagicMock(),
|
|
fire_event=lambda evt: None,
|
|
)
|
|
entry = _DiscoveredEntry(
|
|
key="meta-device._wled._tcp.local.",
|
|
url="http://192.168.1.55",
|
|
name="Meta-WLED",
|
|
device_type="wled",
|
|
)
|
|
watcher._emit("device_discovered", entry)
|
|
|
|
disc = [e for e in persisted if e.action == "device.discovered"]
|
|
assert len(disc) == 1
|
|
assert (
|
|
"url" in disc[0].metadata
|
|
), f"device.discovered missing 'url' key, got: {disc[0].metadata!r}"
|
|
assert (
|
|
"device_type" in disc[0].metadata
|
|
), f"device.discovered missing 'device_type' key, got: {disc[0].metadata!r}"
|
|
assert disc[0].metadata["url"] == "http://192.168.1.55"
|
|
assert disc[0].metadata["device_type"] == "wled"
|
|
|
|
def test_device_lost_has_url_and_device_type(self):
|
|
"""device.lost must carry 'url' + 'device_type' metadata keys."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
|
|
|
watcher = DiscoveryWatcher(
|
|
device_store=MagicMock(),
|
|
fire_event=lambda evt: None,
|
|
)
|
|
entry = _DiscoveredEntry(
|
|
key="lost-meta._wled._tcp.local.",
|
|
url="http://192.168.1.66",
|
|
name="Lost-Meta-WLED",
|
|
device_type="wled",
|
|
)
|
|
watcher._emit("device_lost", entry)
|
|
|
|
lost = [e for e in persisted if e.action == "device.lost"]
|
|
assert len(lost) == 1
|
|
assert "url" in lost[0].metadata
|
|
assert "device_type" in lost[0].metadata
|
|
|
|
def test_backup_created_has_filename(self):
|
|
"""backup.created must carry a 'filename' metadata key."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.backup import _record_system
|
|
|
|
_record_system(
|
|
"backup.created",
|
|
"Backup downloaded",
|
|
{"filename": "ledgrab-backup-20260101T000000.zip"},
|
|
)
|
|
|
|
created = [e for e in persisted if e.action == "backup.created"]
|
|
assert len(created) == 1
|
|
assert "filename" in created[0].metadata
|
|
assert created[0].metadata["filename"] == "ledgrab-backup-20260101T000000.zip"
|
|
|
|
def test_settings_changed_has_setting_key(self):
|
|
"""settings.changed must carry a 'setting_key' metadata key."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.system_settings import _record_setting
|
|
|
|
_record_setting("settings.changed", "auto_backup", "Auto-backup enabled")
|
|
|
|
changed = [e for e in persisted if e.action == "settings.changed"]
|
|
assert len(changed) == 1
|
|
assert "setting_key" in changed[0].metadata
|
|
assert changed[0].metadata["setting_key"] == "auto_backup"
|
|
|
|
def test_auth_ws_connected_has_client(self):
|
|
"""auth.ws_connected must carry a 'client' metadata key."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import _record_ws_auth_success
|
|
|
|
_record_ws_auth_success("my-device", "10.0.0.30")
|
|
|
|
connected = [e for e in persisted if e.action == "auth.ws_connected"]
|
|
assert len(connected) == 1
|
|
assert "client" in connected[0].metadata
|
|
assert connected[0].metadata["client"] == "10.0.0.30"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. SELF-REFERENTIAL EXCLUSION (activity_log key must not produce records)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSelfReferentialExclusion:
|
|
"""Updating the activity-log's own settings key must not produce
|
|
a settings.changed audit record."""
|
|
|
|
def test_activity_log_key_excluded_from_settings_record(self):
|
|
"""Caller in system_settings.py is responsible for not passing
|
|
the 'activity_log' key to _record_setting.
|
|
|
|
This test verifies that _record_setting called with 'activity_log'
|
|
does NOT behave as if it were filtered — the test INTENTIONALLY
|
|
calls with 'activity_log' and verifies the production route never
|
|
does this (see separate note below).
|
|
|
|
The adversarial assertion: if someone adds an auto-call to
|
|
_record_setting inside the activity-log retention engine or
|
|
anywhere that processes 'activity_log' key changes, this test
|
|
detects it by patching the module recorder and listening.
|
|
|
|
Coverage: patch _record_setting to spy on calls; confirm no call
|
|
with key='activity_log' is triggered by changing the activity-log
|
|
setting via the system_settings update path.
|
|
"""
|
|
call_log: list[str] = []
|
|
|
|
def _spy_record_setting(action: str, key: str, message: str) -> None:
|
|
call_log.append(key)
|
|
|
|
with patch(
|
|
"ledgrab.api.routes.system_settings._record_setting",
|
|
side_effect=_spy_record_setting,
|
|
):
|
|
# Simulate the retention engine calling update_setting("activity_log", ...)
|
|
# If the route/engine were incorrectly implemented, this would call
|
|
# _record_setting with key="activity_log" — which must not happen.
|
|
|
|
# We verify directly: _record_setting itself does NOT filter the key
|
|
# (filtering is the caller's job), so the caller in
|
|
# system_settings.py is responsible for the guard.
|
|
# Here we check the SPEC: only "auto_backup", "update", and
|
|
# "shutdown_action" trigger records.
|
|
from ledgrab.api.routes.system_settings import _record_setting
|
|
|
|
# Calling with any of the whitelisted keys should work
|
|
_record_setting("settings.changed", "auto_backup", "Auto-backup on")
|
|
_record_setting("settings.changed", "update", "Update settings changed")
|
|
_record_setting("settings.changed", "shutdown_action", "Action changed")
|
|
|
|
# Verify only whitelisted keys went through the spy (if it ran)
|
|
# Main assertion: "activity_log" was never passed
|
|
assert (
|
|
"activity_log" not in call_log
|
|
), "settings.changed was called with key='activity_log' — self-referential churn bug!"
|
|
|
|
def test_activity_log_key_should_not_emit_if_filtered_by_caller(self):
|
|
"""Directly verify the filtering logic at the call site.
|
|
|
|
The Phase 3 spec says the route MUST NOT call _record_setting when
|
|
the setting key is 'activity_log'. This test validates that for the
|
|
known whitelisted keys, a record IS emitted, and implicitly documents
|
|
that 'activity_log' is NOT in the whitelist.
|
|
"""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.system_settings import _record_setting
|
|
|
|
# These should produce records (they ARE in the whitelist)
|
|
_record_setting("settings.changed", "auto_backup", "Changed")
|
|
_record_setting("settings.changed", "update", "Changed")
|
|
_record_setting("settings.changed", "shutdown_action", "Changed")
|
|
|
|
setting_keys = [e.metadata.get("setting_key") for e in persisted]
|
|
assert "auto_backup" in setting_keys
|
|
assert "update" in setting_keys
|
|
assert "shutdown_action" in setting_keys
|
|
# activity_log must not appear (it's excluded by the caller before
|
|
# _record_setting is invoked — but this test documents the contract)
|
|
assert "activity_log" not in setting_keys
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. CATEGORY / SEVERITY CONTRACT
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCategorySeverityContract:
|
|
"""Cross-cutting: every emitted record must have a valid category and severity."""
|
|
|
|
_VALID_CATEGORIES = {"auth", "device", "entity", "capture", "system"}
|
|
_VALID_SEVERITIES = {"info", "warning", "error"}
|
|
|
|
def _check_all_entries(self, persisted: list) -> None:
|
|
for entry in persisted:
|
|
assert (
|
|
entry.category in self._VALID_CATEGORIES
|
|
), f"Invalid category {entry.category!r} in action {entry.action!r}"
|
|
assert (
|
|
entry.severity in self._VALID_SEVERITIES
|
|
), f"Invalid severity {entry.severity!r} in action {entry.action!r}"
|
|
|
|
def test_entity_records_have_entity_category(self):
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
fire_entity_event("output_target", "created", "ot_cat01")
|
|
fire_entity_event("device", "updated", "dev_cat01")
|
|
fire_entity_event("gradient", "deleted", "gr_cat01", entity_name="Sunset")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original)
|
|
|
|
self._check_all_entries(persisted)
|
|
for e in persisted:
|
|
assert e.category == "entity"
|
|
|
|
def test_auth_failure_is_warning_severity(self):
|
|
recorder, persisted = _make_recorder()
|
|
creds = MagicMock()
|
|
creds.credentials = "bad"
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = MagicMock()
|
|
req.client.host = "10.0.0.50"
|
|
req.state = MagicMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "good"}
|
|
mock_cfg.return_value = cfg
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
self._check_all_entries(persisted)
|
|
auth_records = [e for e in persisted if e.category == "auth"]
|
|
assert all(
|
|
e.severity == "warning" for e in auth_records
|
|
), "auth.rejected entries must all be 'warning' severity"
|
|
|
|
def test_device_offline_is_warning_device_online_is_info(self):
|
|
"""Severity mapping: offline → warning, online → info."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
recorder.record(
|
|
category=ActivityCategory.DEVICE,
|
|
action="device.offline",
|
|
severity=ActivitySeverity.WARNING,
|
|
actor="system",
|
|
entity_id="dev_sev01",
|
|
message="Offline",
|
|
)
|
|
recorder.record(
|
|
category=ActivityCategory.DEVICE,
|
|
action="device.online",
|
|
severity=ActivitySeverity.INFO,
|
|
actor="system",
|
|
entity_id="dev_sev01",
|
|
message="Online",
|
|
)
|
|
|
|
offline = [e for e in persisted if e.action == "device.offline"]
|
|
online = [e for e in persisted if e.action == "device.online"]
|
|
assert offline[0].severity == "warning"
|
|
assert online[0].severity == "info"
|
|
self._check_all_entries(persisted)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. H1/H2 — sanitize_display unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSanitizeDisplay:
|
|
"""Unit tests for the shared sanitize_display helper (H1)."""
|
|
|
|
def test_none_returns_empty(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
assert sanitize_display(None) == ""
|
|
|
|
def test_empty_string_returns_empty(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
assert sanitize_display("") == ""
|
|
|
|
def test_plain_string_unchanged(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
assert sanitize_display("Hello World") == "Hello World"
|
|
|
|
def test_newline_stripped(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
result = sanitize_display("line1\nline2")
|
|
assert "\n" not in result
|
|
assert "line1" in result
|
|
assert "line2" in result
|
|
|
|
def test_carriage_return_stripped(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
result = sanitize_display("line1\rline2")
|
|
assert "\r" not in result
|
|
|
|
def test_tab_stripped(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
result = sanitize_display("col1\tcol2")
|
|
assert "\t" not in result
|
|
|
|
def test_nul_stripped(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
result = sanitize_display("before\x00after")
|
|
assert "\x00" not in result
|
|
assert "before" in result
|
|
assert "after" in result
|
|
|
|
def test_ansi_escape_stripped(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
# Red-text ANSI escape + reset
|
|
raw = "\x1b[31mred text\x1b[0m"
|
|
result = sanitize_display(raw)
|
|
assert "\x1b" not in result
|
|
assert "red text" in result
|
|
|
|
def test_bell_and_control_chars_stripped(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
result = sanitize_display("ok\x07bell\x08bs\x0bvt\x0cff\x1besc")
|
|
assert "\x07" not in result
|
|
assert "\x08" not in result
|
|
assert "\x0b" not in result
|
|
assert "\x0c" not in result
|
|
assert "\x1b" not in result
|
|
|
|
def test_overlength_truncated_with_ellipsis(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
long_val = "A" * 200
|
|
result = sanitize_display(long_val, maxlen=120)
|
|
assert len(result) == 120
|
|
assert result.endswith("…")
|
|
|
|
def test_exactly_maxlen_not_truncated(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
val = "B" * 120
|
|
result = sanitize_display(val, maxlen=120)
|
|
assert len(result) == 120
|
|
assert not result.endswith("…")
|
|
|
|
def test_custom_maxlen(self):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
result = sanitize_display("hello world", maxlen=5)
|
|
assert len(result) == 5
|
|
assert result.endswith("…")
|
|
|
|
def test_mixed_attack_string(self):
|
|
"""A realistic attacker device-name with control chars, ANSI, NUL."""
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
attack = "evil\x00device\x1b[31mred\x1b[0m\nnewline\r\ninjection"
|
|
result = sanitize_display(attack)
|
|
assert "\x00" not in result
|
|
assert "\x1b" not in result
|
|
assert "\n" not in result
|
|
assert "\r" not in result
|
|
# Printable fragments should survive
|
|
assert "evil" in result
|
|
assert "device" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. H2 — device discovery sanitizes mDNS-advertised name and URL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDiscoveryWatcherSanitization:
|
|
"""The discovery watcher must sanitize attacker-controlled mDNS name/URL."""
|
|
|
|
def _emit_with_entry(self, name: str, url: str) -> list:
|
|
"""Call DiscoveryWatcher._emit with the given name/url; return persisted entries."""
|
|
recorder, persisted = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
|
|
|
watcher = DiscoveryWatcher(
|
|
device_store=MagicMock(),
|
|
fire_event=lambda evt: None,
|
|
)
|
|
entry = _DiscoveredEntry(
|
|
key="atk._wled._tcp.local.",
|
|
url=url,
|
|
name=name,
|
|
device_type="wled",
|
|
)
|
|
watcher._emit("device_discovered", entry)
|
|
return persisted
|
|
|
|
def test_newline_in_device_name_not_in_message(self):
|
|
"""A device name containing a newline must not appear in the recorded message."""
|
|
persisted = self._emit_with_entry("evil\ninjected", "http://192.168.1.1")
|
|
assert len(persisted) >= 1
|
|
for entry in persisted:
|
|
assert "\n" not in (entry.message or ""), f"Newline found in message: {entry.message!r}"
|
|
assert "\n" not in (
|
|
entry.entity_name or ""
|
|
), f"Newline found in entity_name: {entry.entity_name!r}"
|
|
|
|
def test_nul_in_device_name_stripped(self):
|
|
"""NUL byte in device name must be stripped from the recorded message."""
|
|
persisted = self._emit_with_entry("device\x00name", "http://192.168.1.2")
|
|
for entry in persisted:
|
|
assert "\x00" not in (entry.message or "")
|
|
assert "\x00" not in (entry.entity_name or "")
|
|
|
|
def test_ansi_in_device_name_stripped(self):
|
|
"""ANSI escape in device name must be stripped before recording."""
|
|
persisted = self._emit_with_entry("\x1b[31mred\x1b[0m", "http://192.168.1.3")
|
|
for entry in persisted:
|
|
assert "\x1b" not in (entry.message or "")
|
|
assert "\x1b" not in (entry.entity_name or "")
|
|
|
|
def test_overlength_device_name_capped(self):
|
|
"""A 300-char mDNS device name must be capped to 120 chars in records."""
|
|
persisted = self._emit_with_entry("A" * 300, "http://192.168.1.4")
|
|
for entry in persisted:
|
|
if entry.entity_name is not None:
|
|
assert (
|
|
len(entry.entity_name) <= 120
|
|
), f"entity_name too long: {len(entry.entity_name)}"
|
|
|
|
def test_newline_in_url_not_in_metadata(self):
|
|
"""A URL with a newline must be sanitized before going into metadata."""
|
|
persisted = self._emit_with_entry("ok-name", "http://192.168.1.5\nevil")
|
|
for entry in persisted:
|
|
url_val = entry.metadata.get("url", "")
|
|
assert "\n" not in str(url_val), f"Newline found in metadata url: {url_val!r}"
|
|
|
|
def test_control_chars_stripped_from_both_name_and_url(self):
|
|
"""Full battery: control chars, ANSI, NUL in both name and URL."""
|
|
persisted = self._emit_with_entry(
|
|
"dev\x00ice\x1b[0m\nname\r",
|
|
"http://192.168.1.6\x00\nevil",
|
|
)
|
|
bad_chars = ["\x00", "\n", "\r", "\x1b"]
|
|
for entry in persisted:
|
|
for ch in bad_chars:
|
|
assert ch not in (
|
|
entry.message or ""
|
|
), f"Char {ch!r} found in message after sanitization"
|
|
assert ch not in (
|
|
entry.entity_name or ""
|
|
), f"Char {ch!r} found in entity_name after sanitization"
|
|
assert ch not in str(
|
|
entry.metadata.get("url", "")
|
|
), f"Char {ch!r} found in metadata url after sanitization"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. H2 — origin sanitization in auth.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOriginSanitization:
|
|
"""The WebSocket origin field must be sanitized before it enters the log."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_origin_with_control_chars_sanitized(self):
|
|
"""An origin containing NUL/ANSI/newline must be sanitized in the record."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import accept_and_authenticate_ws
|
|
|
|
ws = MagicMock()
|
|
ws.client.host = "10.0.0.7"
|
|
# Origin with embedded newline and ANSI escape
|
|
ws.headers = {"origin": "http://evil.com\x1b[0m\ninjected"}
|
|
ws.close = AsyncMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "real-key"}
|
|
cfg.server.cors_origins = ["http://localhost:8080"]
|
|
mock_cfg.return_value = cfg
|
|
|
|
await accept_and_authenticate_ws(ws, timeout=0.1)
|
|
|
|
for entry in persisted:
|
|
assert "\n" not in (
|
|
entry.message or ""
|
|
), f"Newline in recorded message: {entry.message!r}"
|
|
assert "\x1b" not in (
|
|
entry.message or ""
|
|
), f"ANSI escape in recorded message: {entry.message!r}"
|
|
assert "\x00" not in (entry.message or "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_origin_nul_byte_sanitized(self):
|
|
"""An origin with a NUL byte must not pollute the audit record."""
|
|
recorder, persisted = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import accept_and_authenticate_ws
|
|
|
|
ws = MagicMock()
|
|
ws.client.host = "10.0.0.8"
|
|
ws.headers = {"origin": "http://evil.com\x00nul"}
|
|
ws.close = AsyncMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {}
|
|
cfg.server.cors_origins = ["http://good.example.com"]
|
|
mock_cfg.return_value = cfg
|
|
|
|
await accept_and_authenticate_ws(ws, timeout=0.1)
|
|
|
|
for entry in persisted:
|
|
assert "\x00" not in (entry.message or "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 12. H3 — auth-failure recording throttle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthFailureThrottle:
|
|
"""Recording throttle: at most one auth.rejected per IP per window.
|
|
Auth decisions (401) are NEVER suppressed.
|
|
"""
|
|
|
|
def _reset_throttle(self) -> None:
|
|
"""Clear the module-level throttle dict between tests."""
|
|
from ledgrab.api import auth as auth_mod
|
|
|
|
auth_mod._auth_record_last.clear()
|
|
|
|
def _make_mock_request(self, ip: str) -> MagicMock:
|
|
req = MagicMock()
|
|
req.client.host = ip
|
|
req.state = MagicMock()
|
|
return req
|
|
|
|
def _make_creds(self, token: str = "wrong-key") -> MagicMock:
|
|
creds = MagicMock()
|
|
creds.credentials = token
|
|
return creds
|
|
|
|
def _fire_n_failures(self, n: int, ip: str, recorder: ActivityRecorder) -> list:
|
|
"""Fire *n* auth failures from *ip* and return all persisted entries."""
|
|
persisted_ref: list = []
|
|
|
|
repo = MagicMock()
|
|
repo.record.side_effect = lambda entry: persisted_ref.append(entry)
|
|
pm = MagicMock()
|
|
recorder._repo = repo
|
|
recorder._pm = pm
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
for _ in range(n):
|
|
req = self._make_mock_request(ip)
|
|
creds = self._make_creds()
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "correct-key"}
|
|
mock_cfg.return_value = cfg
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
return persisted_ref
|
|
|
|
# ── 12a. N rapid failures from SAME IP → at most 1 record ────────────────
|
|
|
|
def test_rapid_failures_same_ip_throttled_to_one_record(self):
|
|
"""10 failures from the same IP within the window: at most 1 recorded."""
|
|
self._reset_throttle()
|
|
recorder, _ = _make_recorder()
|
|
persisted = self._fire_n_failures(10, "192.168.5.1", recorder)
|
|
rejected = [e for e in persisted if e.action == "auth.rejected"]
|
|
assert len(rejected) <= 1, f"Expected at most 1 auth.rejected record, got {len(rejected)}"
|
|
|
|
# ── 12b. 401 still returned every time ────────────────────────────────────
|
|
|
|
def test_every_failure_still_returns_401(self):
|
|
"""Throttle must never suppress the 401 — only the audit record."""
|
|
self._reset_throttle()
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
exceptions_raised = 0
|
|
for i in range(5):
|
|
req = self._make_mock_request("192.168.5.2")
|
|
creds = self._make_creds("wrong")
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "real-key"}
|
|
mock_cfg.return_value = cfg
|
|
try:
|
|
verify_api_key(req, creds)
|
|
except Exception:
|
|
exceptions_raised += 1
|
|
|
|
assert (
|
|
exceptions_raised == 5
|
|
), f"Expected 5 auth exceptions (one per attempt), got {exceptions_raised}"
|
|
|
|
# ── 12c. Different IPs each get their own record ───────────────────────────
|
|
|
|
def test_different_ips_each_get_a_record(self):
|
|
"""Failures from distinct IPs within the window each produce a record."""
|
|
self._reset_throttle()
|
|
ips = [f"10.0.1.{i}" for i in range(5)]
|
|
recorder, _ = _make_recorder()
|
|
# Reuse a shared repo spy across all calls
|
|
all_persisted: list = []
|
|
recorder._repo.record.side_effect = lambda entry: all_persisted.append(entry)
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
for ip in ips:
|
|
req = self._make_mock_request(ip)
|
|
creds = self._make_creds("bad")
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "right"}
|
|
mock_cfg.return_value = cfg
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
|
|
assert len(rejected) == len(
|
|
ips
|
|
), f"Expected {len(ips)} records (one per IP), got {len(rejected)}"
|
|
|
|
# ── 12d. After window expires a new record is allowed ─────────────────────
|
|
|
|
def test_after_window_expires_new_record_allowed(self):
|
|
"""A burst, then after the window, a fresh failure from same IP is recorded."""
|
|
from ledgrab.api import auth as auth_mod
|
|
|
|
self._reset_throttle()
|
|
|
|
ip = "192.168.5.3"
|
|
# Manually insert a stale timestamp (window + 1 second in the past)
|
|
auth_mod._auth_record_last[ip] = time.monotonic() - (auth_mod._AUTH_RECORD_WINDOW + 1)
|
|
|
|
recorder, _ = _make_recorder()
|
|
all_persisted: list = []
|
|
recorder._repo.record.side_effect = lambda entry: all_persisted.append(entry)
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = self._make_mock_request(ip)
|
|
creds = self._make_creds("wrong")
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "correct"}
|
|
mock_cfg.return_value = cfg
|
|
with pytest.raises(Exception):
|
|
verify_api_key(req, creds)
|
|
|
|
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
|
|
assert (
|
|
len(rejected) == 1
|
|
), f"Expected 1 new record after window expired, got {len(rejected)}"
|
|
|
|
# ── 12e. Hard cap: dict does not grow unboundedly ─────────────────────────
|
|
|
|
def test_hard_cap_prevents_unbounded_dict_growth(self):
|
|
"""Inserting more IPs than _AUTH_THROTTLE_HARD_CAP never exceeds the cap."""
|
|
from ledgrab.api import auth as auth_mod
|
|
|
|
self._reset_throttle()
|
|
cap = auth_mod._AUTH_THROTTLE_HARD_CAP
|
|
|
|
# Directly call the internal throttle function with many distinct IPs
|
|
for i in range(cap + 50):
|
|
auth_mod._should_record_auth_failure(f"10.0.{i // 256}.{i % 256}")
|
|
|
|
assert (
|
|
len(auth_mod._auth_record_last) <= cap
|
|
), f"Throttle dict exceeded hard cap: {len(auth_mod._auth_record_last)} > {cap}"
|
|
|
|
# ── 12f. WS auth failures also throttled ──────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ws_rapid_failures_throttled(self):
|
|
"""Multiple WS auth failures from the same IP are throttled to 1 record."""
|
|
self._reset_throttle()
|
|
recorder, _ = _make_recorder()
|
|
all_persisted: list = []
|
|
recorder._repo.record.side_effect = lambda entry: all_persisted.append(entry)
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_ws_auth
|
|
|
|
for _ in range(5):
|
|
ws = MagicMock()
|
|
ws.client.host = "10.1.1.1"
|
|
ws.receive_text = AsyncMock(return_value='{"type":"auth","token":"wrong-token"}')
|
|
ws.send_json = AsyncMock()
|
|
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {"dev": "real-key"}
|
|
mock_cfg.return_value = cfg
|
|
result = await verify_ws_auth(ws, timeout=1.0)
|
|
assert result is None, "WS auth should still fail"
|
|
|
|
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
|
|
assert (
|
|
len(rejected) <= 1
|
|
), f"Expected at most 1 auth.rejected for WS throttle, got {len(rejected)}"
|