"""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"