Files
ledgrab/server/tests/test_activity_instrumentation.py
alexei.dolgolyov 6745e25b20 feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations
Eight roadmap features from the 2026-06-19 review, each a full vertical
(backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests:

- automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared
  with the daylight cycle; window logic mirrors TimeOfDayRule)
- ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest
  (release.yml; amd64 path untouched, continue-on-error)
- game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared
  runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown)
- ui: color-harmony gradient generator (complementary/analogous/triadic/...)
- effects: audio-reactive palette modulation (new audio_energy_tap; brightness/
  saturation modulation across all 12 procedural effects)
- capture: linear-light blending + spatio-temporal dithering, opt-in per
  calibration (new utils/linear_light.py, utils/dither.py)
- devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode)

Also bundles the pending 2026-06-18 production-review fixes and other
in-progress work already in the working tree (manual-trigger rule, etc.),
since they share files and could not be cleanly separated.

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
2026-06-22 23:21:24 +03:00

616 lines
23 KiB
Python

"""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
import asyncio
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
asyncio.run(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
asyncio.run(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
asyncio.run(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):
asyncio.run(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"