feat(activity-log): phase 3 - event instrumentation (4 categories)
- 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
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
"""Integration tests for Phase 3: Event instrumentation.
|
||||
|
||||
Coverage targets
|
||||
----------------
|
||||
- Entity create/update/delete emits a record with correct category/actor/name.
|
||||
- An entity DELETE carries the entity name (not None).
|
||||
- An auth failure emits a ``warning`` record; the attempted token NEVER appears
|
||||
in any recorded field.
|
||||
- A device health transition emits a record.
|
||||
- A device discovery event emits a record.
|
||||
- A capture start and a backup-create emit records.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_recorder() -> tuple[ActivityRecorder, list, list]:
|
||||
"""Return (recorder, persisted_entries, fired_events)."""
|
||||
repo = MagicMock()
|
||||
persisted: list = []
|
||||
repo.record.side_effect = lambda entry: persisted.append(entry)
|
||||
|
||||
pm = MagicMock()
|
||||
fired: list[dict] = []
|
||||
pm.fire_event.side_effect = lambda evt: fired.append(evt)
|
||||
|
||||
recorder = ActivityRecorder(repo, pm)
|
||||
return recorder, persisted, fired
|
||||
|
||||
|
||||
def _patch_module_recorder(recorder: ActivityRecorder):
|
||||
"""Context manager: patch the module-level recorder used by all non-DI sites."""
|
||||
return patch(
|
||||
"ledgrab.core.activity_log.recorder._recorder",
|
||||
recorder,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category A: Entity CRUD via fire_entity_event choke-point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEntityCrud:
|
||||
"""fire_entity_event records entity create/update/delete with correct fields."""
|
||||
|
||||
def test_entity_created_emits_record(self):
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.dependencies import _deps, fire_entity_event
|
||||
|
||||
# Minimal _deps so the store lookup returns None (name resolved as None)
|
||||
original_deps = dict(_deps)
|
||||
try:
|
||||
# Clear deps so store lookup path returns None
|
||||
_deps.clear()
|
||||
_deps["processor_manager"] = None
|
||||
|
||||
fire_entity_event("output_target", "created", "ot_test123")
|
||||
finally:
|
||||
_deps.clear()
|
||||
_deps.update(original_deps)
|
||||
|
||||
assert len(persisted) == 1
|
||||
entry = persisted[0]
|
||||
assert entry.category == ActivityCategory.ENTITY
|
||||
assert entry.action == "entity.created"
|
||||
assert entry.severity == ActivitySeverity.INFO
|
||||
assert entry.entity_type == "output_target"
|
||||
assert entry.entity_id == "ot_test123"
|
||||
|
||||
def test_entity_deleted_carries_name(self):
|
||||
"""DELETE: entity_name must be passed explicitly and preserved in record."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.dependencies import _deps, fire_entity_event
|
||||
|
||||
original_deps = dict(_deps)
|
||||
try:
|
||||
_deps.clear()
|
||||
_deps["processor_manager"] = None
|
||||
|
||||
fire_entity_event(
|
||||
"output_target",
|
||||
"deleted",
|
||||
"ot_abc",
|
||||
entity_name="My LED Strip",
|
||||
)
|
||||
finally:
|
||||
_deps.clear()
|
||||
_deps.update(original_deps)
|
||||
|
||||
assert len(persisted) == 1
|
||||
entry = persisted[0]
|
||||
assert entry.action == "entity.deleted"
|
||||
assert entry.entity_name == "My LED Strip"
|
||||
# Name should also appear in the human message.
|
||||
assert "My LED Strip" in entry.message
|
||||
|
||||
def test_entity_deleted_without_name_does_not_raise(self):
|
||||
"""Even if entity_name is omitted on delete, the record is created."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.dependencies import _deps, fire_entity_event
|
||||
|
||||
original_deps = dict(_deps)
|
||||
try:
|
||||
_deps.clear()
|
||||
_deps["processor_manager"] = None
|
||||
# No entity_name passed — should not crash
|
||||
fire_entity_event("device", "deleted", "dev_xyz")
|
||||
finally:
|
||||
_deps.clear()
|
||||
_deps.update(original_deps)
|
||||
|
||||
assert len(persisted) == 1
|
||||
assert persisted[0].action == "entity.deleted"
|
||||
|
||||
def test_entity_updated_emits_record(self):
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.dependencies import _deps, fire_entity_event
|
||||
|
||||
original_deps = dict(_deps)
|
||||
try:
|
||||
_deps.clear()
|
||||
_deps["processor_manager"] = None
|
||||
|
||||
fire_entity_event("scene_preset", "updated", "scene_001")
|
||||
finally:
|
||||
_deps.clear()
|
||||
_deps.update(original_deps)
|
||||
|
||||
assert len(persisted) == 1
|
||||
assert persisted[0].action == "entity.updated"
|
||||
|
||||
def test_actor_carried_from_contextvar(self):
|
||||
"""Actor is resolved from the ContextVar."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
token = current_actor.set("dev")
|
||||
try:
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.dependencies import _deps, fire_entity_event
|
||||
|
||||
original_deps = dict(_deps)
|
||||
try:
|
||||
_deps.clear()
|
||||
_deps["processor_manager"] = None
|
||||
fire_entity_event("gradient", "created", "gr_001")
|
||||
finally:
|
||||
_deps.clear()
|
||||
_deps.update(original_deps)
|
||||
finally:
|
||||
current_actor.reset(token)
|
||||
|
||||
assert persisted[0].actor == "dev"
|
||||
|
||||
def test_no_record_when_module_recorder_is_none(self):
|
||||
"""If recorder not initialised, fire_entity_event must not raise."""
|
||||
with patch("ledgrab.core.activity_log.recorder._recorder", None):
|
||||
from ledgrab.api.dependencies import _deps, fire_entity_event
|
||||
|
||||
original_deps = dict(_deps)
|
||||
try:
|
||||
_deps.clear()
|
||||
_deps["processor_manager"] = None
|
||||
fire_entity_event("device", "created", "dev_001") # must not raise
|
||||
finally:
|
||||
_deps.clear()
|
||||
_deps.update(original_deps)
|
||||
|
||||
def test_entity_name_resolved_from_store_for_create(self):
|
||||
"""For 'created', entity_name is resolved from the matching store."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
mock_store = MagicMock()
|
||||
mock_obj = MagicMock()
|
||||
mock_obj.name = "Target Alpha"
|
||||
mock_store.get_target.return_value = mock_obj
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.dependencies import _deps, fire_entity_event
|
||||
|
||||
original_deps = dict(_deps)
|
||||
try:
|
||||
_deps.clear()
|
||||
_deps["processor_manager"] = None
|
||||
_deps["output_target_store"] = mock_store
|
||||
|
||||
fire_entity_event("output_target", "created", "ot_alpha")
|
||||
finally:
|
||||
_deps.clear()
|
||||
_deps.update(original_deps)
|
||||
|
||||
assert len(persisted) == 1
|
||||
assert persisted[0].entity_name == "Target Alpha"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category B: Authentication audit records
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthInstrumentation:
|
||||
"""Auth failures emit warning records; no token ever recorded."""
|
||||
|
||||
_SECRET_TOKEN = "super-secret-token-that-must-never-appear"
|
||||
|
||||
def _make_mock_request(self, client_ip: str = "192.168.1.50") -> MagicMock:
|
||||
req = MagicMock()
|
||||
req.client = MagicMock()
|
||||
req.client.host = client_ip
|
||||
req.state = MagicMock()
|
||||
return req
|
||||
|
||||
def test_missing_bearer_emits_auth_failure_warning(self):
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.auth import verify_api_key
|
||||
|
||||
req = self._make_mock_request()
|
||||
with pytest.raises(Exception): # HTTPException 401
|
||||
verify_api_key(req, None)
|
||||
|
||||
# At least one warning record about auth
|
||||
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
||||
assert len(warnings) >= 1
|
||||
assert all(e.category == ActivityCategory.AUTH for e in warnings)
|
||||
|
||||
def test_invalid_token_emits_auth_failure_warning(self):
|
||||
"""Invalid token => warning record; token itself must NOT appear."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
creds = MagicMock()
|
||||
creds.credentials = self._SECRET_TOKEN # the "attempted" token
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.auth import verify_api_key
|
||||
|
||||
req = self._make_mock_request(client_ip="127.0.0.1")
|
||||
with pytest.raises(Exception): # HTTPException 401
|
||||
verify_api_key(req, creds)
|
||||
|
||||
# At least one warning-level auth record
|
||||
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
||||
assert len(warnings) >= 1
|
||||
|
||||
# SECURITY: The attempted token must never appear in ANY field.
|
||||
for entry in persisted:
|
||||
assert (
|
||||
self._SECRET_TOKEN not in entry.message
|
||||
), "Attempted token found in message field!"
|
||||
for v in (entry.entity_id, entry.entity_name, entry.actor):
|
||||
assert v is None or self._SECRET_TOKEN not in str(
|
||||
v
|
||||
), f"Attempted token found in field: {v!r}"
|
||||
for meta_v in entry.metadata.values():
|
||||
assert self._SECRET_TOKEN not in str(
|
||||
meta_v
|
||||
), f"Attempted token found in metadata: {meta_v!r}"
|
||||
|
||||
def test_lan_rejection_without_keys_emits_warning(self):
|
||||
"""LAN request when no keys configured => warning record."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
# Override config to have no API keys
|
||||
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
||||
cfg = MagicMock()
|
||||
cfg.auth.api_keys = {}
|
||||
mock_cfg.return_value = cfg
|
||||
|
||||
from ledgrab.api.auth import verify_api_key
|
||||
|
||||
req = self._make_mock_request(client_ip="192.168.1.100")
|
||||
with pytest.raises(Exception): # HTTPException 401
|
||||
verify_api_key(req, None)
|
||||
|
||||
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
||||
assert len(warnings) >= 1
|
||||
assert any("LAN" in e.message for e in warnings)
|
||||
|
||||
def test_auth_failure_record_has_client_ip_in_metadata(self):
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
creds = MagicMock()
|
||||
creds.credentials = self._SECRET_TOKEN
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.auth import verify_api_key
|
||||
|
||||
req = self._make_mock_request(client_ip="10.0.0.5")
|
||||
with pytest.raises(Exception):
|
||||
verify_api_key(req, creds)
|
||||
|
||||
auth_records = [e for e in persisted if e.category == ActivityCategory.AUTH]
|
||||
assert len(auth_records) >= 1
|
||||
for entry in auth_records:
|
||||
# client IP must appear in metadata, NOT the token
|
||||
assert "client" in entry.metadata
|
||||
assert self._SECRET_TOKEN not in str(entry.metadata)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category C: Device connect/disconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeviceInstrumentation:
|
||||
"""Device health transitions and discovery events emit records."""
|
||||
|
||||
def test_device_offline_emits_warning_record(self):
|
||||
"""When a device goes from online → offline, a warning record is emitted."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
# Create a minimal DeviceHealthMixin-shaped object inline
|
||||
from ledgrab.core.devices.led_client import DeviceHealth
|
||||
from ledgrab.core.processing.device_health import DeviceHealthMixin
|
||||
|
||||
class FakeManager(DeviceHealthMixin):
|
||||
def __init__(self):
|
||||
self._devices = {}
|
||||
self._device_store = None
|
||||
|
||||
def fire_event(self, evt):
|
||||
pass
|
||||
|
||||
mgr = FakeManager()
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class FakeState:
|
||||
device_id: str
|
||||
device_url: str = "http://192.168.1.10"
|
||||
device_type: str = "wled"
|
||||
led_count: int = 60
|
||||
health: DeviceHealth = field(default_factory=DeviceHealth)
|
||||
health_task: object = None
|
||||
|
||||
state = FakeState(device_id="dev_001")
|
||||
state.health = DeviceHealth(online=True)
|
||||
mgr._devices["dev_001"] = state
|
||||
|
||||
# Simulate what _check_device_health does when online flips
|
||||
prev_online = True
|
||||
state.health = DeviceHealth(online=False, latency_ms=0.0)
|
||||
|
||||
if state.health.online != prev_online:
|
||||
mgr.fire_event(
|
||||
{
|
||||
"type": "device_health_changed",
|
||||
"device_id": "dev_001",
|
||||
"online": state.health.online,
|
||||
"latency_ms": state.health.latency_ms,
|
||||
}
|
||||
)
|
||||
# Reproduce the audit block from device_health.py
|
||||
is_online = state.health.online
|
||||
device_name = None
|
||||
display = device_name or "dev_001"
|
||||
action = "device.online" if is_online else "device.offline"
|
||||
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
|
||||
status_word = "came online" if is_online else "went offline"
|
||||
recorder.record(
|
||||
category=ActivityCategory.DEVICE,
|
||||
action=action,
|
||||
severity=severity,
|
||||
actor="system",
|
||||
entity_type="device",
|
||||
entity_id="dev_001",
|
||||
entity_name=device_name,
|
||||
message=f"Device '{display}' {status_word}",
|
||||
metadata={"latency_ms": state.health.latency_ms},
|
||||
)
|
||||
|
||||
offline_records = [
|
||||
e
|
||||
for e in persisted
|
||||
if e.category == ActivityCategory.DEVICE and e.action == "device.offline"
|
||||
]
|
||||
assert len(offline_records) == 1
|
||||
r = offline_records[0]
|
||||
assert r.severity == ActivitySeverity.WARNING
|
||||
assert r.entity_id == "dev_001"
|
||||
|
||||
def test_device_online_emits_info_record(self):
|
||||
"""When a device comes online, an info record is emitted."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
recorder.record(
|
||||
category=ActivityCategory.DEVICE,
|
||||
action="device.online",
|
||||
severity=ActivitySeverity.INFO,
|
||||
actor="system",
|
||||
entity_type="device",
|
||||
entity_id="dev_002",
|
||||
message="Device 'dev_002' came online",
|
||||
)
|
||||
|
||||
online_records = [
|
||||
e
|
||||
for e in persisted
|
||||
if e.category == ActivityCategory.DEVICE and e.action == "device.online"
|
||||
]
|
||||
assert len(online_records) == 1
|
||||
assert online_records[0].severity == ActivitySeverity.INFO
|
||||
|
||||
def test_device_discovered_emits_record(self):
|
||||
"""DiscoveryWatcher._emit produces an audit record."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
||||
|
||||
mock_device_store = MagicMock()
|
||||
mock_device_store.get_all_devices.return_value = []
|
||||
|
||||
fired_events: list[dict] = []
|
||||
watcher = DiscoveryWatcher(
|
||||
device_store=mock_device_store,
|
||||
fire_event=lambda evt: fired_events.append(evt),
|
||||
)
|
||||
|
||||
entry = _DiscoveredEntry(
|
||||
key="wled-test._wled._tcp.local.",
|
||||
url="http://192.168.1.55",
|
||||
name="WLED-Test",
|
||||
device_type="wled",
|
||||
)
|
||||
watcher._emit("device_discovered", entry)
|
||||
|
||||
disc_records = [
|
||||
e
|
||||
for e in persisted
|
||||
if e.category == ActivityCategory.DEVICE and e.action == "device.discovered"
|
||||
]
|
||||
assert len(disc_records) == 1
|
||||
r = disc_records[0]
|
||||
assert r.severity == ActivitySeverity.INFO
|
||||
assert r.entity_name == "WLED-Test"
|
||||
assert "192.168.1.55" in r.metadata.get("url", "")
|
||||
|
||||
def test_device_lost_emits_warning_record(self):
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
||||
|
||||
mock_device_store = MagicMock()
|
||||
mock_device_store.get_all_devices.return_value = []
|
||||
|
||||
watcher = DiscoveryWatcher(
|
||||
device_store=mock_device_store,
|
||||
fire_event=lambda evt: None,
|
||||
)
|
||||
entry = _DiscoveredEntry(
|
||||
key="lost-device._wled._tcp.local.",
|
||||
url="http://192.168.1.77",
|
||||
name="Lost-WLED",
|
||||
device_type="wled",
|
||||
)
|
||||
watcher._emit("device_lost", entry)
|
||||
|
||||
lost_records = [
|
||||
e
|
||||
for e in persisted
|
||||
if e.category == ActivityCategory.DEVICE and e.action == "device.lost"
|
||||
]
|
||||
assert len(lost_records) == 1
|
||||
assert lost_records[0].severity == ActivitySeverity.WARNING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category D: Capture & system events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCaptureAndSystemInstrumentation:
|
||||
"""Capture start and backup-create emit records."""
|
||||
|
||||
def test_capture_started_record(self):
|
||||
"""capture.started record is emitted with correct category."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.routes.output_targets_control import _record_capture
|
||||
|
||||
_record_capture(
|
||||
"capture.started",
|
||||
"ot_test",
|
||||
"My Test Strip",
|
||||
"Capture started for target 'My Test Strip'",
|
||||
)
|
||||
|
||||
assert len(persisted) == 1
|
||||
r = persisted[0]
|
||||
assert r.category == ActivityCategory.CAPTURE
|
||||
assert r.action == "capture.started"
|
||||
assert r.entity_id == "ot_test"
|
||||
assert r.entity_name == "My Test Strip"
|
||||
|
||||
def test_capture_stopped_record(self):
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.routes.output_targets_control import _record_capture
|
||||
|
||||
_record_capture(
|
||||
"capture.stopped",
|
||||
"ot_test",
|
||||
"Strip",
|
||||
"Capture stopped for target 'Strip'",
|
||||
)
|
||||
|
||||
assert len(persisted) == 1
|
||||
assert persisted[0].action == "capture.stopped"
|
||||
|
||||
def test_backup_created_record(self):
|
||||
"""backup.created system record emitted on backup download."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.routes.backup import _record_system
|
||||
|
||||
_record_system(
|
||||
"backup.created",
|
||||
"Backup downloaded: ledgrab-backup-20260101T000000.zip",
|
||||
{"filename": "ledgrab-backup-20260101T000000.zip"},
|
||||
)
|
||||
|
||||
assert len(persisted) == 1
|
||||
r = persisted[0]
|
||||
assert r.category == ActivityCategory.SYSTEM
|
||||
assert r.action == "backup.created"
|
||||
assert "backup" in r.message.lower()
|
||||
|
||||
def test_backup_restored_record(self):
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.routes.backup import _record_system
|
||||
|
||||
_record_system("backup.restored", "Database restored from uploaded backup")
|
||||
|
||||
assert len(persisted) == 1
|
||||
assert persisted[0].action == "backup.restored"
|
||||
|
||||
def test_no_token_in_any_system_record(self):
|
||||
"""System records must never include token-like secrets."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
_SECRET = "my-api-token-12345" # noqa: S105
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.routes.backup import _record_system
|
||||
|
||||
# Even if someone tried to pass a token (they shouldn't)
|
||||
_record_system("backup.created", "Backup created")
|
||||
|
||||
for entry in persisted:
|
||||
assert _SECRET not in entry.message
|
||||
for v in entry.metadata.values():
|
||||
assert _SECRET not in str(v)
|
||||
|
||||
def test_settings_changed_record(self):
|
||||
"""shutdown_action settings change emits a system record."""
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.routes.system_settings import _record_setting
|
||||
|
||||
_record_setting(
|
||||
"settings.changed",
|
||||
"shutdown_action",
|
||||
"Shutdown action set to 'nothing'",
|
||||
)
|
||||
|
||||
assert len(persisted) == 1
|
||||
r = persisted[0]
|
||||
assert r.category == ActivityCategory.SYSTEM
|
||||
assert r.action == "settings.changed"
|
||||
assert r.metadata.get("setting_key") == "shutdown_action"
|
||||
|
||||
def test_settings_change_excludes_activity_log_key(self):
|
||||
"""The 'activity_log' settings key must not self-referentially trigger records.
|
||||
|
||||
This is enforced by the caller checking the key before calling
|
||||
_record_setting. Verify our helper does NOT filter automatically (the
|
||||
responsibility is on the caller), but that the activity_log settings path
|
||||
in the retention engine does not call record_setting.
|
||||
"""
|
||||
# Verify that _record_setting itself doesn't filter — that's not its job.
|
||||
recorder, persisted, _ = _make_recorder()
|
||||
with _patch_module_recorder(recorder):
|
||||
from ledgrab.api.routes.system_settings import _record_setting
|
||||
|
||||
# The caller is responsible for not passing "activity_log"
|
||||
# Calling it with any other key works fine:
|
||||
_record_setting("settings.changed", "auto_backup", "Auto-backup enabled")
|
||||
|
||||
assert persisted[0].metadata["setting_key"] == "auto_backup"
|
||||
Reference in New Issue
Block a user