123da1b5c4
Security: - Force API key auth for LAN (non-loopback) requests; remove shipped dev key - Block path-traversal in backup restore; require auth on backup endpoints - SSRF protection: DNS resolve + private/loopback/link-local IP rejection - AES-256-GCM encryption for HA tokens and MQTT passwords with auto-migration - WebSocket auth migrated from query-string to first-message protocol - Asset upload: extension allowlist, server-side mime, Content-Disposition - Update installer: SHA256 verification, tar/zip member validation - Tightened CORS (explicit methods/headers, no credentials) - ADB serial regex allowlist, webhook rate-limit key fix, log scrubbing Android: - Root-capture: ordered teardown, screenrecord respawn watchdog, child reaping - USB permission blocking API via CompletableDeferred - Python init crash guard with fatal-error screen - Moved root grant + QR generation off Main thread - Cached PyObject engine for per-frame bridge calls - Ordered ScreenCapture resource cleanup, allowBackup=false Python: - Replaced all asyncio.get_event_loop() with get_running_loop/to_thread - Split color_strip_sources.py (1683->5 files) and color_strip_stream.py (1324->7 files) into packages - Extracted FrameLimiter utility, migrated 9 stream loops - Provider base-class reuse, WLED state caching + URL normalization - Narrowed broad except-pass in WS routes, threading fixes in BaseStore Frontend: - XSS fix: escapeHtml on dynamic option labels, reconcile-based list renders - Typed DOM helpers, safe localStorage access, AbortController listener hygiene - openAuthedWs helper for first-message WS auth protocol - Migrated remaining plain <select>s to IconSelect/EntitySelect Design: - WCAG AA primary color on light theme (#2e7d32, 5.4:1 contrast) - Android TV 10-foot breakpoint (tv.css) - Consolidated z-index tokens, unified easing, card-running GPU hints
294 lines
8.9 KiB
Python
294 lines
8.9 KiB
Python
"""Pytest configuration and shared fixtures.
|
|
|
|
IMPORTANT: This conftest patches the global config singleton BEFORE any test
|
|
module can import ``ledgrab.main``. ``main.py`` reads ``get_config()``
|
|
at module level to open the database — if the singleton is not patched first,
|
|
the REAL production database (``data/ledgrab.db``) is opened and tests
|
|
read/write/delete production data.
|
|
"""
|
|
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ISOLATE ALL TESTS FROM PRODUCTION DATA — must happen before any test module
|
|
# imports ``ledgrab.main``.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import ledgrab.config as _config_mod # noqa: E402
|
|
|
|
_test_tmp = Path(tempfile.mkdtemp(prefix="wled_test_"))
|
|
_test_db_path = str(_test_tmp / "test_ledgrab.db")
|
|
_test_assets_dir = str(_test_tmp / "test_assets")
|
|
|
|
_original_config = _config_mod.Config.load()
|
|
_test_config = _original_config.model_copy(
|
|
update={
|
|
"storage": _config_mod.StorageConfig(database_file=_test_db_path),
|
|
"assets": _config_mod.AssetsConfig(
|
|
assets_dir=_test_assets_dir,
|
|
max_file_size_mb=_original_config.assets.max_file_size_mb,
|
|
),
|
|
# Inject a test API key so auth-enforcement tests have something
|
|
# concrete to authenticate against and reject when omitted.
|
|
"auth": _config_mod.AuthConfig(api_keys={"test": "test-api-key-12345"}),
|
|
},
|
|
)
|
|
_config_mod.config = _test_config
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
from ledgrab.config import Config, StorageConfig, ServerConfig, AuthConfig # noqa: E402
|
|
from ledgrab.storage.database import Database # noqa: E402
|
|
from ledgrab.storage.device_store import Device, DeviceStore # noqa: E402
|
|
from ledgrab.storage.sync_clock import SyncClock # noqa: E402
|
|
from ledgrab.storage.sync_clock_store import SyncClockStore # noqa: E402
|
|
from ledgrab.storage.output_target_store import OutputTargetStore # noqa: E402
|
|
from ledgrab.storage.automation import Automation # noqa: E402
|
|
from ledgrab.storage.automation_store import AutomationStore # noqa: E402
|
|
from ledgrab.storage.value_source_store import ValueSourceStore # noqa: E402
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Directory / path fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def test_data_dir(tmp_path):
|
|
"""Provide a temporary directory for test data."""
|
|
d = tmp_path / "data"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
@pytest.fixture
|
|
def test_config_dir(tmp_path):
|
|
"""Provide a temporary directory for test configuration."""
|
|
return tmp_path / "config"
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_store_dir(tmp_path):
|
|
"""Provide a temp directory for store files, cleaned up after tests."""
|
|
d = tmp_path / "stores"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Database fixture
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_db(tmp_path):
|
|
"""Provide a temporary SQLite Database instance."""
|
|
db = Database(tmp_path / "test.db")
|
|
yield db
|
|
db.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def test_config(tmp_path):
|
|
"""A Config instance with temp directories for all store files."""
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
storage = StorageConfig(
|
|
database_file=str(data_dir / "test.db"),
|
|
)
|
|
|
|
return Config(
|
|
server=ServerConfig(host="127.0.0.1", port=9999),
|
|
auth=AuthConfig(api_keys={"test": "test-api-key-12345"}),
|
|
storage=storage,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Store fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def device_store(tmp_db):
|
|
"""Provide a DeviceStore backed by a temp database."""
|
|
return DeviceStore(tmp_db)
|
|
|
|
|
|
@pytest.fixture
|
|
def sync_clock_store(tmp_db):
|
|
"""Provide a SyncClockStore backed by a temp database."""
|
|
return SyncClockStore(tmp_db)
|
|
|
|
|
|
@pytest.fixture
|
|
def output_target_store(tmp_db):
|
|
"""Provide an OutputTargetStore backed by a temp database."""
|
|
return OutputTargetStore(tmp_db)
|
|
|
|
|
|
@pytest.fixture
|
|
def automation_store(tmp_db):
|
|
"""Provide an AutomationStore backed by a temp database."""
|
|
return AutomationStore(tmp_db)
|
|
|
|
|
|
@pytest.fixture
|
|
def value_source_store(tmp_db):
|
|
"""Provide a ValueSourceStore backed by a temp database."""
|
|
return ValueSourceStore(tmp_db)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample entity factories
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_device():
|
|
"""Provide a sample device configuration dict."""
|
|
return {
|
|
"id": "test_device_001",
|
|
"name": "Test WLED Device",
|
|
"url": "http://192.168.1.100",
|
|
"led_count": 150,
|
|
"enabled": True,
|
|
"device_type": "wled",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def make_device():
|
|
"""Factory fixture: call make_device(name=..., **overrides) to build a Device."""
|
|
_counter = 0
|
|
|
|
def _factory(name=None, **kwargs):
|
|
nonlocal _counter
|
|
_counter += 1
|
|
defaults = dict(
|
|
device_id=f"device_test_{_counter:04d}",
|
|
name=name or f"Device {_counter}",
|
|
url=f"http://192.168.1.{_counter}",
|
|
led_count=150,
|
|
)
|
|
defaults.update(kwargs)
|
|
return Device(**defaults)
|
|
|
|
return _factory
|
|
|
|
|
|
@pytest.fixture
|
|
def make_sync_clock():
|
|
"""Factory fixture: call make_sync_clock(name=..., **overrides)."""
|
|
_counter = 0
|
|
|
|
def _factory(name=None, **kwargs):
|
|
nonlocal _counter
|
|
_counter += 1
|
|
now = datetime.now(timezone.utc)
|
|
defaults = dict(
|
|
id=f"sc_test_{_counter:04d}",
|
|
name=name or f"Clock {_counter}",
|
|
speed=1.0,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
defaults.update(kwargs)
|
|
return SyncClock(**defaults)
|
|
|
|
return _factory
|
|
|
|
|
|
@pytest.fixture
|
|
def make_automation():
|
|
"""Factory fixture: call make_automation(name=..., **overrides)."""
|
|
_counter = 0
|
|
|
|
def _factory(name=None, **kwargs):
|
|
nonlocal _counter
|
|
_counter += 1
|
|
now = datetime.now(timezone.utc)
|
|
defaults = dict(
|
|
id=f"auto_test_{_counter:04d}",
|
|
name=name or f"Automation {_counter}",
|
|
enabled=True,
|
|
rule_logic="or",
|
|
rules=[],
|
|
scene_preset_id=None,
|
|
deactivation_mode="none",
|
|
deactivation_scene_preset_id=None,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
defaults.update(kwargs)
|
|
return Automation(**defaults)
|
|
|
|
return _factory
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Authenticated test client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def authenticated_client(test_config, monkeypatch):
|
|
"""Provide a FastAPI TestClient with auth header pre-set.
|
|
|
|
Patches global config so the app uses temp storage paths.
|
|
"""
|
|
import ledgrab.config as config_mod
|
|
|
|
monkeypatch.setattr(config_mod, "config", test_config)
|
|
|
|
from fastapi.testclient import TestClient
|
|
from ledgrab.main import app
|
|
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
client.headers["Authorization"] = "Bearer test-api-key-12345"
|
|
return client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calibration sample (kept from original conftest)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_calibration():
|
|
"""Provide a sample calibration configuration."""
|
|
return {
|
|
"layout": "clockwise",
|
|
"start_position": "bottom_left",
|
|
"segments": [
|
|
{"edge": "bottom", "led_start": 0, "led_count": 40, "reverse": False},
|
|
{"edge": "right", "led_start": 40, "led_count": 30, "reverse": False},
|
|
{"edge": "top", "led_start": 70, "led_count": 40, "reverse": True},
|
|
{"edge": "left", "led_start": 110, "led_count": 40, "reverse": True},
|
|
],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session cleanup — remove temporary test directory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _cleanup_test_tmp():
|
|
"""Remove the temporary test directory after all tests complete."""
|
|
import shutil
|
|
|
|
yield
|
|
shutil.rmtree(_test_tmp, ignore_errors=True)
|