6745e25b20
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.
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""Regression tests for the 2026-06-18 production-readiness review fixes.
|
|
|
|
Each test maps to a confirmed finding from the review and would fail against
|
|
the pre-fix code. Grouped by area; see the inline finding tags (#N).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from ledgrab.storage.activity_log import (
|
|
ActivityCategory,
|
|
ActivityLogEntry,
|
|
ActivityLogFilters,
|
|
ActivitySeverity,
|
|
)
|
|
from ledgrab.storage.activity_log_repository import ActivityLogRepository
|
|
from ledgrab.storage.database import Database
|
|
|
|
|
|
def _entry(*, ts: datetime | None = None, message: str = "m") -> ActivityLogEntry:
|
|
return ActivityLogEntry(
|
|
id="al_" + uuid.uuid4().hex[:8],
|
|
ts=ts or datetime.now(timezone.utc),
|
|
category=ActivityCategory.SYSTEM,
|
|
action="test.action",
|
|
severity=ActivitySeverity.INFO,
|
|
actor="system",
|
|
message=message,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(tmp_db: Database) -> ActivityLogRepository:
|
|
return ActivityLogRepository(tmp_db)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #8 — activity-log entry id has full 128-bit entropy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_new_id_uses_full_uuid_hex():
|
|
from ledgrab.core.activity_log.recorder import _new_id
|
|
|
|
val = _new_id()
|
|
assert val.startswith("al_")
|
|
hex_part = val[3:]
|
|
assert len(hex_part) == 32 # full uuid4 hex (was 8 → collision-prone)
|
|
int(hex_part, 16) # parses as hex
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #26 — sanitize_display honours its bounded-length contract for tiny maxlen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("maxlen", [0, 1, 2, 5])
|
|
def test_sanitize_display_respects_bound(maxlen):
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
result = sanitize_display("abcdef", maxlen=maxlen)
|
|
assert len(result) <= maxlen
|
|
|
|
|
|
def test_sanitize_display_degenerate_maxlen_returns_empty():
|
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
|
|
|
assert sanitize_display("abcdef", maxlen=0) == ""
|
|
assert sanitize_display("abcdef", maxlen=-3) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #10 — non-ASCII Bearer / WS token does not raise from compare_digest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_match_api_key_non_ascii_token_returns_none_not_raises():
|
|
from ledgrab.api.auth import _match_api_key
|
|
|
|
with patch("ledgrab.api.auth.get_config") as cfg:
|
|
c = MagicMock()
|
|
c.auth.api_keys = {"dev": "correct-key"}
|
|
cfg.return_value = c
|
|
# café contains a non-ASCII char; must cleanly fail to match, not raise.
|
|
assert _match_api_key("café") is None
|
|
assert _match_api_key("correct-key") == "dev"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #15 — game adapters tolerate non-ASCII attacker-controlled tokens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_generic_webhook_adapter_non_ascii_header_returns_false():
|
|
from ledgrab.core.game_integration.adapters.generic_webhook_adapter import (
|
|
GenericWebhookAdapter,
|
|
)
|
|
|
|
ok = GenericWebhookAdapter.validate_auth(
|
|
{"Authorization": "Bearer café"}, {}, {"auth_token": "secret123"}
|
|
)
|
|
assert ok is False # no TypeError
|
|
|
|
|
|
def test_cs2_adapter_non_ascii_payload_token_returns_false():
|
|
from ledgrab.core.game_integration.adapters.cs2_adapter import CS2Adapter
|
|
|
|
ok = CS2Adapter.validate_auth({}, {"auth": {"token": "café"}}, {"auth_token": "secret123"})
|
|
assert ok is False # no TypeError
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #2 — async auth dependency sets the actor ContextVar visibly to the handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_async_auth_dep_actor_visible_to_handler():
|
|
import asyncio
|
|
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
# Guard against a regression back to a sync dependency (which would run the
|
|
# contextvar mutation in a throwaway threadpool context the handler can't see).
|
|
assert asyncio.iscoroutinefunction(verify_api_key)
|
|
|
|
from fastapi import Depends, FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ledgrab.core.activity_log.context import current_actor
|
|
|
|
app = FastAPI()
|
|
|
|
# Use Depends() as a default value rather than the ``AuthRequired`` Annotated
|
|
# alias: this module has ``from __future__ import annotations`` (stringized
|
|
# annotations), and the route is defined in a local scope FastAPI can't
|
|
# resolve the alias from — the default-value form sidesteps that entirely.
|
|
@app.get("/whoami")
|
|
async def whoami(auth=Depends(verify_api_key)):
|
|
return {"label": auth, "actor": current_actor.get()}
|
|
|
|
with patch("ledgrab.api.auth.get_config") as cfg:
|
|
c = MagicMock()
|
|
c.auth.api_keys = {"dev": "k"}
|
|
cfg.return_value = c
|
|
client = TestClient(app)
|
|
resp = client.get("/whoami", headers={"Authorization": "Bearer k"})
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["label"] == "dev"
|
|
# Pre-fix (sync dep) this would be "system"; the async dep makes it visible.
|
|
assert body["actor"] == "dev"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #9 / #12 — auth-failure throttle dict survives concurrent access
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_should_record_auth_failure_concurrent_no_exception():
|
|
from ledgrab.api import auth as auth_mod
|
|
|
|
auth_mod._auth_record_last.clear()
|
|
# Pre-fill to one below the hard cap so the eviction branch is hot.
|
|
cap = auth_mod._AUTH_THROTTLE_HARD_CAP
|
|
for i in range(cap - 1):
|
|
auth_mod._auth_record_last[f"seed-{i}"] = 0.0
|
|
|
|
errors: list[BaseException] = []
|
|
|
|
def hammer(base: int):
|
|
try:
|
|
for j in range(500):
|
|
auth_mod._should_record_auth_failure(f"{base}.{j}")
|
|
except BaseException as exc: # noqa: BLE001 - capture any escape
|
|
errors.append(exc)
|
|
|
|
threads = [threading.Thread(target=hammer, args=(t,)) for t in range(16)]
|
|
for th in threads:
|
|
th.start()
|
|
for th in threads:
|
|
th.join(timeout=30)
|
|
|
|
assert not errors, f"throttle raised under concurrency: {errors[:3]}"
|
|
assert len(auth_mod._auth_record_last) <= cap
|
|
auth_mod._auth_record_last.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #1 / #4 — since/until filter normalises naive + non-UTC-offset datetimes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_until_boundary_includes_entry_at_exact_instant(repo: ActivityLogRepository):
|
|
# Entry stored at exactly 12:00 UTC.
|
|
instant = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc)
|
|
repo.record(_entry(ts=instant, message="boundary"))
|
|
|
|
# A naive datetime-local value at the same wall-clock (no offset) — the
|
|
# realistic frontend path. Pre-fix this lexically excluded the row.
|
|
naive_until = datetime(2026, 6, 18, 12, 0, 0) # noqa: DTZ001 - intentional naive
|
|
page = repo.query(ActivityLogFilters(until=naive_until), limit=10)
|
|
assert len(page) == 1
|
|
|
|
|
|
def test_since_with_non_utc_offset_includes_correct_instant(repo: ActivityLogRepository):
|
|
# Stored at 13:30 UTC.
|
|
repo.record(_entry(ts=datetime(2026, 6, 18, 13, 30, tzinfo=timezone.utc), message="x"))
|
|
|
|
# since expressed in +02:00 == 13:00 UTC → row (13:30Z) must be included.
|
|
since_incl = datetime(2026, 6, 18, 15, 0, tzinfo=timezone(timedelta(hours=2)))
|
|
assert len(repo.query(ActivityLogFilters(since=since_incl), limit=10)) == 1
|
|
|
|
# since == 16:00+02:00 == 14:00 UTC → row (13:30Z) must be excluded.
|
|
since_excl = datetime(2026, 6, 18, 16, 0, tzinfo=timezone(timedelta(hours=2)))
|
|
assert len(repo.query(ActivityLogFilters(since=since_excl), limit=10)) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #21 — iter_export advances the keyset cursor correctly across batches
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_iter_export_multi_batch_no_gaps_or_dupes(repo: ActivityLogRepository):
|
|
n = 7
|
|
for i in range(n):
|
|
repo.record(_entry(message=f"e{i}"))
|
|
|
|
# batch_size=2 over 7 rows → 4 batches, exercising cursor advancement.
|
|
exported = list(repo.iter_export(batch_size=2))
|
|
ids = [e.id for e in exported]
|
|
assert len(ids) == n
|
|
assert len(set(ids)) == n # no duplicates across batch boundaries
|
|
# Identical set to a single-batch run.
|
|
assert set(ids) == {e.id for e in repo.iter_export(batch_size=1000)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #13 — undecryptable secret envelope is preserved, not discarded
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_decrypt_failure_preserves_envelope(monkeypatch):
|
|
from ledgrab.storage import game_integration as gi
|
|
|
|
envelope = "ENC:v1:undecryptable-blob"
|
|
monkeypatch.setattr(gi.secret_box, "is_encrypted", lambda v: v == envelope)
|
|
|
|
def _boom(_v):
|
|
raise ValueError("secret key missing")
|
|
|
|
monkeypatch.setattr(gi.secret_box, "decrypt", _boom)
|
|
|
|
result = gi._decrypt_adapter_config({"auth_token": envelope, "other": "x"})
|
|
# Pre-fix: result["auth_token"] == "" (data loss on the next write-through).
|
|
assert result["auth_token"] == envelope
|
|
assert result["other"] == "x"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #22 — real-thread / real-DB concurrency on the repository
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_repo_concurrent_writes_are_consistent(repo: ActivityLogRepository):
|
|
threads_n, per_thread = 8, 100
|
|
total = threads_n * per_thread
|
|
|
|
def worker():
|
|
for _ in range(per_thread):
|
|
repo.record(_entry())
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(threads_n)]
|
|
for th in threads:
|
|
th.start()
|
|
for th in threads:
|
|
th.join(timeout=60)
|
|
|
|
assert repo.count() == total
|
|
exported = list(repo.iter_export(batch_size=50))
|
|
assert len(exported) == total
|
|
assert len({e.id for e in exported}) == total # unique, no corruption
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #23 — CSV export strips control chars from string cells (defense-in-depth)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_csv_export_strips_control_chars(repo: ActivityLogRepository):
|
|
import csv
|
|
import io
|
|
|
|
from ledgrab.api.routes.activity_log import _CSV_COLUMNS, _export_csv_generator
|
|
|
|
evil = "evil\x00dev\x1b[31mred\x1b[0m\r\ninject"
|
|
repo.record(_entry(message=evil))
|
|
|
|
text = b"".join(_export_csv_generator(repo, ActivityLogFilters())).decode("utf-8")
|
|
rows = list(csv.reader(io.StringIO(text)))
|
|
assert len(rows) == 2 # header + 1 data row (newline did not split the field)
|
|
msg_cell = rows[1][_CSV_COLUMNS.index("message")]
|
|
for bad in ("\x00", "\x1b", "\r", "\n"):
|
|
assert bad not in msg_cell, f"control char {bad!r} survived into the CSV cell"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #44 — non-serialisable metadata is dropped best-effort, never raises
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_non_serializable_metadata_does_not_raise():
|
|
from ledgrab.core.activity_log.recorder import ActivityRecorder
|
|
|
|
persisted: list = []
|
|
repo = MagicMock()
|
|
# Route through to_row() so the real json.dumps codec runs (and raises).
|
|
repo.record.side_effect = lambda entry: persisted.append(entry.to_row())
|
|
recorder = ActivityRecorder(repo, MagicMock(), loop=None)
|
|
|
|
# Must not raise into the caller despite the un-encodable values.
|
|
recorder.record(
|
|
category=ActivityCategory.SYSTEM,
|
|
action="bad.metadata",
|
|
message="m",
|
|
metadata={"when": datetime.now(timezone.utc), "tags": {1, 2, 3}},
|
|
)
|
|
assert persisted == [], "non-serialisable entry must not persist"
|