fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI)

Multi-dimension review of v0.8.2. Excludes the deliberately deferred
default_config.yaml weak-default-key item (C1).

Backend:
- calibration: create_default_calibration no longer exceeds led_count for small
  odd counts (bounded trim + regression test)
- game-integration: generic webhook now requires auth_token; constant-time
  compare_digest in all adapters; per-IP failed-auth rate limit on the ingest
  route; auth_token encrypted at rest via secret_box (migration-safe)
- playlist engine: serialize _state/_task under the lifecycle lock to close a
  delete-mid-play race (+ concurrency tests)
- main: stop the calibration session on shutdown (restore prior target)
- home_assistant: validate HA host via the LAN classifier on create/update
- perf: drop slow preview-WS clients instead of blocking the send loop; cache
  composite full-strip resize linspaces; effect_stream lava reuses scratch

Frontend:
- setup/auto-calibration wizard: guard _state after awaits (cancel-safe), await
  session teardown before output start, busy-gate skip-calibration, manual
  display input keeps focus, move focus on step change
- calibration: destroy EntitySelect on modal close
- color-strips test: dirty-flag-gated render + cached ctx/ImageData
- a11y/TV: focus-visible for new wizard/auto-cal/corner controls, aria-labels on
  the spatial corner/edge picker; theme-aware syntax tokens; dead/undefined CSS
  tokens removed; .modal-error styled; i18n titles (en/ru/zh)

Android:
- ApiKeyManager: EncryptedSharedPreferences with verified, data-safe legacy
  migration that never rotates an existing key
- CaptureService: validate MediaProjection token before promoting; satisfy the
  startForeground 5s contract on the bail path
- NotificationListener: connection-scoped executor with lazy fallback
- BLE: request BLUETOOTH_SCAN/CONNECT at runtime + guard handler-thread
  SecurityExceptions
- Root: cancellation-aware su grant probe

Adds 14 tests. Gate: ruff + tsc 5.9.3 + esbuild + pytest (2185 passed) +
compileDebugKotlin all green.
This commit is contained in:
2026-06-09 16:35:08 +03:00
parent 7a12f39f49
commit 17dd2e02ba
42 changed files with 1358 additions and 152 deletions
@@ -5,6 +5,7 @@ import pytest
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping, GameIntegrationConfig
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import secret_box
@pytest.fixture
@@ -272,3 +273,83 @@ class TestGetReferences:
config = store.create_integration(name="Test", adapter_type="webhook")
refs = store.get_references(config.id)
assert refs == []
# ---------------------------------------------------------------------------
# Secret encryption (adapter_config.auth_token) tests
# ---------------------------------------------------------------------------
class TestAdapterConfigSecretEncryption:
def test_auth_token_round_trips(self):
config = GameIntegrationConfig.create_from_kwargs(
name="Webhook",
adapter_type="generic_webhook",
adapter_config={"auth_token": "super-secret", "auth_header": "X-Token"},
)
restored = GameIntegrationConfig.from_dict(config.to_dict())
# Decrypted at runtime — plaintext is recovered.
assert restored.adapter_config["auth_token"] == "super-secret"
# Non-secret fields are untouched.
assert restored.adapter_config["auth_header"] == "X-Token"
def test_stored_token_is_not_plaintext(self):
config = GameIntegrationConfig.create_from_kwargs(
name="Webhook",
adapter_type="generic_webhook",
adapter_config={"auth_token": "plaintext-secret"},
)
stored = config.to_dict()
raw_token = stored["adapter_config"]["auth_token"]
assert raw_token != "plaintext-secret"
assert secret_box.is_encrypted(raw_token)
def test_legacy_plaintext_row_still_decrypts(self):
# Simulate a row written before encryption was added: auth_token is
# bare plaintext (not an ENC: envelope). It must still load.
legacy = {
"id": "gi_legacy01",
"name": "Legacy Webhook",
"adapter_type": "generic_webhook",
"enabled": True,
"adapter_config": {"auth_token": "legacy-plaintext"},
"event_mappings": [],
"created_at": "2024-01-01T00:00:00+00:00",
"updated_at": "2024-01-01T00:00:00+00:00",
}
restored = GameIntegrationConfig.from_dict(legacy)
assert restored.adapter_config["auth_token"] == "legacy-plaintext"
def test_no_auth_token_key_is_safe(self):
config = GameIntegrationConfig.create_from_kwargs(
name="NoSecret",
adapter_type="cs2",
adapter_config={"max_gold": 99999},
)
restored = GameIntegrationConfig.from_dict(config.to_dict())
assert restored.adapter_config == {"max_gold": 99999}
def test_db_stores_token_encrypted(self, tmp_db):
store = GameIntegrationStore(tmp_db)
store.create_integration(
name="Webhook",
adapter_type="generic_webhook",
adapter_config={"auth_token": "db-secret"},
)
rows = tmp_db.load_all("game_integrations")
assert len(rows) == 1
raw_token = rows[0]["adapter_config"]["auth_token"]
assert raw_token != "db-secret"
assert secret_box.is_encrypted(raw_token)
def test_db_round_trip_after_reload(self, tmp_db):
store1 = GameIntegrationStore(tmp_db)
created = store1.create_integration(
name="Webhook",
adapter_type="generic_webhook",
adapter_config={"auth_token": "reload-secret"},
)
# New store instance reads from disk and must decrypt.
store2 = GameIntegrationStore(tmp_db)
fetched = store2.get_integration(created.id)
assert fetched.adapter_config["auth_token"] == "reload-secret"