Files
Anthias/conftest.py
Viktor Petersson eaf6d9fcd5 feat(webview): per-asset custom HTTP headers for web assets (#3162)
* 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>
2026-07-09 09:50:33 +01:00

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