mirror of
https://github.com/Screenly/Anthias.git
synced 2026-07-30 09:15:51 -04:00
* feat(webview): per-asset custom HTTP headers for web assets
Adds a per-asset setting that injects custom request headers (e.g. an
Authorization token for a private Grafana dashboard) into QtWebEngine
when a webpage asset is displayed. Works on both Qt5 and Qt6 via one
non-gated code path. Closes the request in the linked feature issue.
- Store headers in Asset.metadata['headers'] as {name: value}; no
migration (mirrors the refresh_interval_s feature end-to-end)
- Validate/sanitise header names (RFC 7230 token) and reject CR/LF/NUL
in values to prevent header splitting; cap count and length
- v2 API: custom_headers read + write fields folded into metadata
- Edit modal: webpage-only "Custom HTTP headers" textarea, one
Name: Value per line, parsed + clamped in assets_update
- Viewer sends headers as JSON over a new setRequestHeaders D-Bus slot
(version-skew latched like setReloadInterval); reloads on header change
- C++ RequestHeaderInterceptor on the shared QWebEngineProfile, scoped
to the asset's own host so tokens never leak to third-party requests
- Fix host-only test isolation: move shared redis/dbus/env mocks to a
root conftest.py so the api/tests tree inherits them, and reset the
ViewerPublisher/ReplyCollector singletons per test
- Add unit, v2 API, and viewer D-Bus tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(viewer): self-heal a transient setRequestHeaders failure
Hardware testing on x86/Qt6 surfaced a race: the first setRequestHeaders
D-Bus call after a cold webview spawn can lose to the webview's D-Bus
registration and fail transiently. On a single-asset playlist the
unchanged-URL short-circuit then skips loadPage forever, so the C++
interceptor never receives the staged headers and the page loads without
them.
- _apply_request_headers now returns whether the headers are in effect
- view_webpage only caches current_browser_headers on success, so a
transient failure leaves the mismatch in place and the next asset_loop
tick re-issues both setRequestHeaders and loadPage
- add a regression test for the transient-retry path
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): make root conftest a no-op when the app stack is absent
The root conftest that carries the Redis/D-Bus/env isolation is also an
ancestor of tools/raspberry_pi_imager/tests, which the run-python-linter
job runs with -p no:django in a minimal venv (no Django, no pytz). Its
import-time connect_to_redis patch pulled in anthias_common.utils ->
pytz and crashed conftest collection for those app-free tests.
Gate every app-dependent hook (connect_to_redis patch, _mock_redis and
_ensure_assetdir autouse fixtures) on an import probe so the file
degrades to a no-op when the app isn't importable, while still providing
full isolation for the app test suites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): origin-scope injected headers, fix interceptor lifetime
Addresses PR review (Copilot + CodeQL):
- Scope custom headers to the asset's full origin (scheme + host + port)
instead of host only, so an Authorization header can't ride an
https->http downgrade or reach a different port on the same host.
- Parent the request interceptor to the process-lifetime default profile
(not the View) and detach it in ~View, removing a potential
use-after-free where the profile could outlive the interceptor.
- v2 custom_headers validation raises a fixed, self-authored message
rather than echoing the caught exception text (CodeQL "information
exposure through an exception").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): cache a copy of applied request headers, not the caller's dict
Copilot re-review: assigning current_browser_headers = headers aliased
the caller's dict, so a later in-place mutation could make the next
value comparison spuriously equal and skip a needed reload. Store
dict(headers) instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(viewer): defer navigation when request headers can't be applied
Copilot re-review (security): on a transient setRequestHeaders failure
during an asset transition, view_webpage still issued loadPage. The C++
side then reused the PREVIOUS asset's staged headers and scoped them to
the new asset's origin — leaking e.g. a prior asset's Authorization
header to the next asset's host.
Now loadPage is skipped entirely when the header update didn't apply;
the URL/header cache is left stale so the next asset_loop tick retries
once the D-Bus call succeeds. Supersedes the earlier "cache only on
success" tweak.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): reject non-string custom-header values instead of coercing
Copilot re-review: DictField(child=CharField(...)) coerced numeric /
boolean JSON values to strings ({"X": 123} -> "123") before
validate_asset_headers could reject them, so the strict validation was
bypassed. Drop the CharField child (use the unvalidated child) on both
the create and update serializers so values reach validate_asset_headers
verbatim and its isinstance(str) gate 400s non-strings. Also avoids
CharField trimming a value we forward on the wire byte-for-byte.
Adds numeric / bool / null rejection cases to the API test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(review): say same-origin, not same-host, throughout
Copilot re-review (8 doc nits): the origin-scoping change left comments,
docstrings, and the edit-modal copy still describing "same-host" /
"same-site" scoping. Align all of them with the implementation
(same-origin = scheme + host + port), and correct the root conftest
comment to note ENVIRONMENT/dbus-stub setup still runs while only the
app-dependent hooks no-op when the app stack is absent. No logic change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): narrow conftest probe + validate headers at the D-Bus boundary
Copilot re-review:
- conftest app-availability probe caught bare Exception, which would
silently disable Redis isolation for the whole app suite on any real
import error. Narrow to ImportError (the missing-pytz/Django case);
let genuine app import failures surface.
- C++ setRequestHeaders forwarded arbitrary JSON names/values into
info.setHttpHeader() unchecked. Add defensive re-validation at this
trust-no-one D-Bus boundary (RFC 7230 name token, reject CR/LF/NUL
values, cap count/lengths) mirroring validate_asset_headers, so a
hostile/buggy caller can't put splitting bytes on the wire even though
the server side is the primary gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): byte-length header cap + doc accuracy
Copilot re-review:
- MAX_HEADER_VALUE_LEN now caps the UTF-8 byte length (value.encode),
not the Python character count, matching the webview's byte-based cap
(values go on the wire as UTF-8).
- models.py comment updated to same-origin, and to note the server side
is the primary gate with the webview re-validating defensively.
- conftest docstring: correct the module path to
src/anthias_viewer/__init__.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
368 lines
15 KiB
Python
368 lines
15 KiB
Python
"""
|
|
Repository-root pytest configuration.
|
|
|
|
This file holds the isolation that lets the unit-test suite run on a
|
|
developer's host without Docker, without a Redis server, and without the
|
|
system PyGObject stack the viewer service normally requires. It lives at
|
|
the **repo root** — not under ``tests/`` — on purpose: pytest only loads
|
|
a ``conftest.py`` for tests at or below its directory, and the two test
|
|
trees (``tests/`` and ``src/anthias_server/api/tests/``) have no common
|
|
ancestor other than the root. Putting the Redis/D-Bus/env isolation here
|
|
means ``pytest src/anthias_server/api/tests`` in isolation gets the same
|
|
fake-Redis wiring as a full run; previously that isolation lived only in
|
|
``tests/conftest.py`` and silently didn't apply to the API tree, so every
|
|
viewer-notifying view (asset create / update / delete → issue #2430)
|
|
``ConnectionError``'d against ``redis:6379`` whenever the API tests were
|
|
run on their own. The Playwright/marketing fixtures stay in
|
|
``tests/conftest.py`` since only the integration suite under ``tests/``
|
|
uses them.
|
|
|
|
Three concerns are handled here, in order:
|
|
|
|
1. Force ``ENVIRONMENT=test`` so ``anthias_server.django_project/settings.py`` selects
|
|
the SQLite test-DB branch (a repo-local path under ``BASE_DIR`` by
|
|
default; CI overrides via ``ANTHIAS_TEST_DB_PATH``).
|
|
|
|
2. Stub ``gi`` / ``gi.repository`` / ``pydbus`` in ``sys.modules`` *before*
|
|
any application module is imported. ``src/anthias_viewer/__init__.py`` does
|
|
``import pydbus`` at module load, and ``pydbus`` in turn imports
|
|
``gi.repository.Gio`` — which only resolves on hosts with the
|
|
distribution's ``python3-gi`` package installed and wired into the
|
|
active interpreter. The stubs let the import succeed; tests that
|
|
exercise dbus paths mock the relevant calls themselves.
|
|
|
|
3. Replace ``anthias_common.utils.connect_to_redis`` with a dict-backed
|
|
``MagicMock`` factory before any test module imports it, then expose
|
|
the same fake via an autouse fixture. Several modules call
|
|
``r = connect_to_redis()`` at import time
|
|
(``anthias_server.celery_tasks``, ``anthias_server.lib.github``, ``anthias_server.lib.telemetry``, ...); patching
|
|
the factory once at conftest load time means the module-level ``r``
|
|
bindings hold a fake, not a client pointed at host ``redis``.
|
|
"""
|
|
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
import types
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 0. App-availability probe
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# This conftest lives at the repo root so the Redis/D-Bus/env isolation
|
|
# reaches BOTH ``tests/`` and ``src/anthias_server/api/tests/`` (their only
|
|
# common ancestor). But the root is also an ancestor of
|
|
# ``tools/raspberry_pi_imager/tests/``, which the ``run-python-linter`` CI
|
|
# job runs with ``-p no:django`` in a *minimal* venv that has neither
|
|
# Django nor pytz. Importing the app stack there (``anthias_common.utils``
|
|
# pulls in ``pytz``) would crash conftest collection for those tests.
|
|
#
|
|
# So gate every app-dependent hook on whether the app is importable at
|
|
# all. When it isn't (the rpi-imager lint env), those hooks (the
|
|
# connect_to_redis patch, the _mock_redis / _ensure_assetdir autouse
|
|
# fixtures) degrade to no-ops and the app-free tests run untouched. The
|
|
# cheap, dependency-free setup — ENVIRONMENT=test and the gi/pydbus
|
|
# stubs — still runs unconditionally; it's harmless for those tests and
|
|
# needed by everything else.
|
|
try:
|
|
import anthias_common.utils as _anthias_common_utils # noqa: F401
|
|
|
|
_APP_AVAILABLE = True
|
|
except ImportError:
|
|
# Only a missing dependency (the rpi-imager lint env lacks Django /
|
|
# pytz) should flip us to no-op. Catch ImportError specifically, not
|
|
# bare Exception: a genuine error inside the app package (SyntaxError,
|
|
# a broken import-time side effect) must surface loudly rather than
|
|
# silently disabling Redis isolation for the whole app test suite.
|
|
_APP_AVAILABLE = False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. ENVIRONMENT=test (settings.py reads this at import time)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
os.environ.setdefault('ENVIRONMENT', 'test')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Stub gi / gi.repository / pydbus before viewer modules are loaded
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _install_dbus_stubs() -> None:
|
|
"""
|
|
Insert stand-in modules so ``import pydbus`` (and its transitive
|
|
``from gi.repository import Gio, GLib, GObject``) succeeds on
|
|
hosts that don't have the full PyGObject + pydbus stack.
|
|
|
|
Three host shapes show up in the wild:
|
|
|
|
1. Both ``gi`` and ``pydbus`` available (target image): no-op.
|
|
2. ``gi`` missing entirely (typical dev laptop without
|
|
``python3-gi`` apt package): stub both, because the real
|
|
pydbus's module-load does ``from gi.repository import
|
|
GLib, GObject`` and our minimal gi stub can't satisfy that.
|
|
3. ``gi`` present but ``pydbus`` not pip-installed: stub only
|
|
pydbus.
|
|
"""
|
|
gi_missing = importlib.util.find_spec('gi') is None
|
|
pydbus_missing = importlib.util.find_spec('pydbus') is None
|
|
|
|
if gi_missing:
|
|
gi_module = types.ModuleType('gi')
|
|
gi_repository = types.ModuleType('gi.repository')
|
|
# MagicMock for the GLib/Gio/GObject surface — pydbus only
|
|
# touches these when a bus is actually constructed (e.g.
|
|
# ``pydbus.SessionBus()`` inside a function body); tests that
|
|
# hit those paths mock them themselves.
|
|
setattr(gi_repository, 'Gio', MagicMock(name='gi.repository.Gio'))
|
|
setattr(gi_repository, 'GLib', MagicMock(name='gi.repository.GLib'))
|
|
setattr(
|
|
gi_repository, 'GObject', MagicMock(name='gi.repository.GObject')
|
|
)
|
|
setattr(gi_module, 'repository', gi_repository)
|
|
sys.modules['gi'] = gi_module
|
|
sys.modules['gi.repository'] = gi_repository
|
|
|
|
if pydbus_missing or gi_missing:
|
|
pydbus_module = types.ModuleType('pydbus')
|
|
setattr(
|
|
pydbus_module, 'SessionBus', MagicMock(name='pydbus.SessionBus')
|
|
)
|
|
setattr(pydbus_module, 'SystemBus', MagicMock(name='pydbus.SystemBus'))
|
|
sys.modules['pydbus'] = pydbus_module
|
|
|
|
|
|
_install_dbus_stubs()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Defensive Redis mock — replace connect_to_redis() everywhere
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_fake_redis() -> MagicMock:
|
|
"""
|
|
A dict-backed Redis mock matching the surface our code uses.
|
|
|
|
String ops (get/set/delete/expire/exists/flushdb/publish) and list
|
|
ops (rpush/lpop/blpop) are modelled on the real Redis semantics so
|
|
test paths that exercise both — notably ``ReplyCollector.recv_json``
|
|
via BLPOP — see realistic behaviour rather than no-ops.
|
|
"""
|
|
store: dict[str, Any] = {}
|
|
|
|
fake = MagicMock(name='FakeRedis')
|
|
fake.get.side_effect = store.get
|
|
|
|
def _set(
|
|
key: str,
|
|
value: Any,
|
|
*,
|
|
nx: bool = False,
|
|
ex: int | None = None,
|
|
**_: Any,
|
|
) -> bool | None:
|
|
# Match real Redis ``SET ... NX``: succeeds only if the key is
|
|
# not already present, returns True on success / None on no-op.
|
|
# Tests for SETNX-based gates (per-asset recheck cooldown,
|
|
# sweep singleton lock, splash IP-refresh debounce) rely on
|
|
# this — without it, every "is the lock held?" check would
|
|
# spuriously believe the lock was free.
|
|
if nx and key in store:
|
|
return None
|
|
store[key] = value
|
|
return True
|
|
|
|
fake.set.side_effect = _set
|
|
fake.delete.side_effect = lambda *keys: sum(
|
|
1 for k in keys if store.pop(k, None) is not None
|
|
)
|
|
fake.expire.side_effect = lambda key, _ttl: bool(key in store)
|
|
fake.exists.side_effect = lambda *keys: sum(1 for k in keys if k in store)
|
|
fake.flushdb.side_effect = lambda: store.clear()
|
|
fake.publish.side_effect = lambda channel, msg: 0
|
|
|
|
def _eval(script: str, numkeys: int, *args: Any) -> Any:
|
|
# Compare-and-delete is the only ``EVAL`` script in the
|
|
# codebase (sweep lock release in anthias_server.celery_tasks.py). Implement
|
|
# that pattern directly; any other script becomes a no-op.
|
|
if "redis.call('get', KEYS[1])" in script and "'del'" in script:
|
|
keys = list(args[:numkeys])
|
|
argv = list(args[numkeys:])
|
|
if keys and argv and store.get(keys[0]) == argv[0]:
|
|
store.pop(keys[0], None)
|
|
return 1
|
|
return 0
|
|
return None
|
|
|
|
fake.eval.side_effect = _eval
|
|
|
|
def _rpush(key: str, *values: Any) -> int:
|
|
bucket = store.setdefault(key, [])
|
|
bucket.extend(values)
|
|
return len(bucket)
|
|
|
|
def _lpop(key: str) -> Any:
|
|
bucket = store.get(key)
|
|
if not bucket:
|
|
return None
|
|
value = bucket.pop(0)
|
|
if not bucket:
|
|
store.pop(key, None)
|
|
return value
|
|
|
|
def _blpop(keys: Any, timeout: float | None = None) -> Any:
|
|
# Real BLPOP blocks until a value is available or the timeout
|
|
# expires. Tests don't drive a writer thread, so block-then-
|
|
# poll behaviour collapses to "return immediately": yield the
|
|
# first available value, or None if every key is empty.
|
|
if isinstance(keys, (str, bytes)):
|
|
keys = [keys]
|
|
for key in keys:
|
|
value = _lpop(key)
|
|
if value is not None:
|
|
return (key, value)
|
|
return None
|
|
|
|
fake.rpush.side_effect = _rpush
|
|
fake.lpop.side_effect = _lpop
|
|
fake.blpop.side_effect = _blpop
|
|
return fake
|
|
|
|
|
|
# Patch ``connect_to_redis`` at the source so the module-level
|
|
# ``r = connect_to_redis()`` bindings in anthias_server.celery_tasks / anthias_server.lib.github /
|
|
# anthias_server.lib.telemetry / api.views.mixins / viewer / etc. all resolve to the
|
|
# fake the moment those modules are first imported.
|
|
_SESSION_FAKE_REDIS = _make_fake_redis()
|
|
|
|
|
|
def _patch_connect_to_redis() -> None:
|
|
import anthias_common.utils as _lib_utils
|
|
|
|
_lib_utils.connect_to_redis = lambda: _SESSION_FAKE_REDIS
|
|
|
|
|
|
if _APP_AVAILABLE:
|
|
_patch_connect_to_redis()
|
|
|
|
|
|
@pytest.fixture(scope='session', autouse=True)
|
|
def _ensure_assetdir() -> None:
|
|
"""
|
|
Some legacy fixtures (e.g. ``api/tests/test_v1_endpoints.py::
|
|
cleanup_asset_dir``) iterate ``settings['assetdir']`` during
|
|
teardown without creating it first. The Docker test image creates
|
|
``/data/anthias_assets`` in its build (see
|
|
``docker/Dockerfile.test.j2``); local hosts have no such guarantee.
|
|
Materialise the path once per session so those fixtures don't
|
|
``FileNotFoundError`` out before the test even runs.
|
|
"""
|
|
if not _APP_AVAILABLE:
|
|
return
|
|
from anthias_server.settings import settings as _anthias_settings
|
|
|
|
asset_dir = _anthias_settings.get('assetdir')
|
|
if asset_dir:
|
|
os.makedirs(asset_dir, exist_ok=True)
|
|
|
|
|
|
def pytest_collection_modifyitems(
|
|
config: pytest.Config, items: list[pytest.Item]
|
|
) -> None:
|
|
"""Set ``DJANGO_ALLOW_ASYNC_UNSAFE=1`` only when the run actually
|
|
contains integration tests.
|
|
|
|
Playwright's sync API spins up an internal asyncio loop to talk
|
|
to the browser over the CDP socket; Django's ORM detects that
|
|
loop and refuses sync calls (``SynchronousOnlyOperation``) unless
|
|
this flag is set. The single-threaded test process never invokes
|
|
ORM and Playwright concurrently, so the safety net Django enforces
|
|
isn't doing useful work for this suite.
|
|
|
|
Setting it process-wide unconditionally would also disable the
|
|
safety net for unit tests, where an accidental ORM call from
|
|
inside an event loop is a real bug we want Django to flag. Hooking
|
|
at collection time means a unit-only run (``pytest -m "not
|
|
integration"``) leaves the variable unset and the check active;
|
|
a run that includes integration tests sets it once before
|
|
pytest-django's DB setup (which would otherwise hit the same
|
|
check itself).
|
|
"""
|
|
if any('integration' in item.keywords for item in items):
|
|
os.environ['DJANGO_ALLOW_ASYNC_UNSAFE'] = '1'
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_redis(monkeypatch: pytest.MonkeyPatch) -> Iterator[MagicMock]:
|
|
"""
|
|
Replace ``anthias_common.utils.connect_to_redis`` with a dict-backed
|
|
``MagicMock`` for every test, including any module-level
|
|
``r = connect_to_redis()`` bindings that fixtures import indirectly.
|
|
|
|
Tests that need their own Redis mock (e.g. ``tests/test_telemetry.py``,
|
|
``tests/test_messaging.py``) override this by patching ``module.r``
|
|
directly — that takes precedence inside the per-test setup chain.
|
|
|
|
No-op (yields a bare fake) when the app stack isn't importable — the
|
|
app-free ``tools/raspberry_pi_imager/tests`` collected under this root
|
|
conftest must not drag in Django / redis / pytz.
|
|
"""
|
|
if not _APP_AVAILABLE:
|
|
yield _make_fake_redis()
|
|
return
|
|
fake = _make_fake_redis()
|
|
monkeypatch.setattr('anthias_common.utils.connect_to_redis', lambda: fake)
|
|
|
|
# Replace already-bound ``r`` attributes on modules that called
|
|
# connect_to_redis() at import time. Only modules already in
|
|
# sys.modules are touched — others get the fake on first import via
|
|
# the conftest-level patch above.
|
|
for module_path in (
|
|
'anthias_server.app.views',
|
|
'anthias_server.api.views.mixins',
|
|
'anthias_server.api.views.v2',
|
|
'anthias_server.celery_tasks',
|
|
'anthias_server.lib.github',
|
|
'anthias_server.lib.telemetry',
|
|
'anthias_viewer',
|
|
):
|
|
mod = sys.modules.get(module_path)
|
|
if mod is not None and hasattr(mod, 'r'):
|
|
monkeypatch.setattr(f'{module_path}.r', fake)
|
|
|
|
# ``ViewerPublisher`` / ``ReplyCollector`` are process-wide singletons
|
|
# (``INSTANCE`` classvar) that bind ``self._redis = connect_to_redis()``
|
|
# exactly once, in ``__init__``. Patching ``connect_to_redis`` above
|
|
# doesn't reach an instance an earlier test already built, so a
|
|
# singleton first constructed while pointed at the real ``redis`` host
|
|
# would leak that live client into every later test — any view that
|
|
# calls ``send_to_viewer`` (asset create / update / delete → issue
|
|
# #2430 viewer wake) would then ``ConnectionError`` on a host without
|
|
# Redis. Seed both singletons with this test's fake so the view's
|
|
# ``get_instance()`` returns a fake-backed instance regardless of what
|
|
# any prior test left behind, and clear them afterwards so nothing
|
|
# leaks forward.
|
|
from anthias_server.settings import ReplyCollector, ViewerPublisher
|
|
|
|
def _seed_singleton(cls: Any) -> None:
|
|
# Bypass __init__ (which both raises if an instance exists and
|
|
# calls the real connect_to_redis) and inject the fake directly.
|
|
instance = cls.__new__(cls)
|
|
instance._redis = fake
|
|
cls.INSTANCE = instance
|
|
|
|
_seed_singleton(ViewerPublisher)
|
|
_seed_singleton(ReplyCollector)
|
|
|
|
yield fake
|
|
|
|
ViewerPublisher.INSTANCE = None
|
|
ReplyCollector.INSTANCE = None
|