9aada75381
test_fallback_task_retained_until_fire asserts len(_bg_tasks) == 1, but the set carries pending tasks from earlier tests' fallback schedules, so the assertion saw the accumulated count instead. Drop the references (no .cancel() — the tasks belong to closed loops, cross-loop cancel raises RuntimeError on the next test's setup).
385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""Temporary per-module DEBUG overrides with auto-revert.
|
|
|
|
Covers the in-memory service module + a smoke pass over the API layer
|
|
using ``dependency_overrides`` to bypass auth. The APScheduler glue is
|
|
exercised via the fallback asyncio-timer path since tests run without a
|
|
running scheduler.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test scaffolding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _reset_state() -> None:
|
|
"""Clear the module-level ``_active`` dict and any pending fallback
|
|
tasks between tests so prior activations don't bleed across cases.
|
|
|
|
The ``_bg_tasks`` set retains tasks created via the asyncio-fallback
|
|
schedule path; without this, a test that schedules a 30-minute revert
|
|
leaves a pending task that inflates the ``len(_bg_tasks)`` invariant
|
|
checked by ``test_fallback_task_retained_until_fire``.
|
|
"""
|
|
from notify_bridge_server.services import diagnostic_mode as svc
|
|
|
|
svc._active.clear()
|
|
# ``_bg_tasks`` from a previous test belong to that test's now-closed
|
|
# event loop — calling ``.cancel()`` here crosses loops and raises
|
|
# ``RuntimeError: Event loop is closed``. Dropping the references is
|
|
# enough: the tasks can't fire on a dead loop, and CPython will GC
|
|
# them once the prior loop releases them.
|
|
svc._bg_tasks.clear()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_db_read(monkeypatch):
|
|
"""Default every test to a fixed empty ``log_levels`` snapshot.
|
|
|
|
A test that wants to exercise DB-override precedence overrides this
|
|
fixture by re-patching the function explicitly.
|
|
"""
|
|
async def fake() -> str:
|
|
return ""
|
|
|
|
from notify_bridge_server.services import diagnostic_mode as svc
|
|
|
|
monkeypatch.setattr(svc, "_read_db_log_levels", fake)
|
|
|
|
|
|
def _patch_db_read(monkeypatch, value: str) -> None:
|
|
"""Override the auto-applied fixture for a single test that needs a
|
|
non-empty ``log_levels`` value."""
|
|
async def fake() -> str:
|
|
return value
|
|
|
|
from notify_bridge_server.services import diagnostic_mode as svc
|
|
|
|
monkeypatch.setattr(svc, "_read_db_log_levels", fake)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests — service module
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_diagnostic_applies_debug_immediately(tmp_data_dir) -> None: # noqa: ARG001
|
|
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
|
|
|
|
_reset_state()
|
|
module = "notify_bridge_core.notifications.telegram.client"
|
|
|
|
entry = await set_diagnostic(module, duration_minutes=30)
|
|
|
|
assert entry["module"] == module
|
|
assert entry["current_level"] == "DEBUG"
|
|
assert entry["remaining_seconds"] > 60 * 29
|
|
assert logging.getLogger(module).level == logging.DEBUG
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_diagnostic_rejects_unlisted_module(tmp_data_dir) -> None: # noqa: ARG001
|
|
"""Only the documented namespaces should be flippable from the UI."""
|
|
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
|
|
|
|
_reset_state()
|
|
with pytest.raises(ValueError, match="allowlist"):
|
|
await set_diagnostic("some_random_third_party", 30)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_diagnostic_rejects_root_logger(tmp_data_dir) -> None: # noqa: ARG001
|
|
"""The empty string would target root — explicitly disallowed."""
|
|
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
|
|
|
|
_reset_state()
|
|
with pytest.raises(ValueError, match="allowlist"):
|
|
await set_diagnostic("", 30)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_diagnostic_rejects_unreasonable_durations(tmp_data_dir) -> None: # noqa: ARG001
|
|
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
|
|
|
|
_reset_state()
|
|
with pytest.raises(ValueError, match="duration_minutes"):
|
|
await set_diagnostic("notify_bridge_core", 0)
|
|
with pytest.raises(ValueError, match="duration_minutes"):
|
|
await set_diagnostic("notify_bridge_core", 9999)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_baseline_from_db_override(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
|
|
"""``log_levels`` setting wins over the noisy-library default."""
|
|
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
|
|
|
|
_reset_state()
|
|
_patch_db_read(monkeypatch, "sqlalchemy.engine=ERROR")
|
|
entry = await set_diagnostic("sqlalchemy.engine", duration_minutes=15)
|
|
assert entry["baseline_level"] == "ERROR"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_baseline_from_noisy_default(tmp_data_dir) -> None: # noqa: ARG001
|
|
"""No DB override falls through to the curated noisy-lib quiet list."""
|
|
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
|
|
|
|
_reset_state()
|
|
entry = await set_diagnostic("sqlalchemy.engine", duration_minutes=15)
|
|
assert entry["baseline_level"] == "WARNING"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_baseline_prefix_walks_for_submodule(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
|
|
"""A sub-logger like ``sqlalchemy.engine.Engine`` inherits its parent's
|
|
noisy-default level (WARNING), not the root INFO."""
|
|
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
|
|
|
|
_reset_state()
|
|
entry = await set_diagnostic(
|
|
"sqlalchemy.engine.Engine", duration_minutes=15,
|
|
)
|
|
assert entry["baseline_level"] == "WARNING"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_baseline_prefix_walks_for_db_override(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
|
|
"""An explicit ``log_levels`` entry covers all sub-loggers below it."""
|
|
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
|
|
|
|
_reset_state()
|
|
_patch_db_read(
|
|
monkeypatch, "notify_bridge_core.notifications=ERROR",
|
|
)
|
|
entry = await set_diagnostic(
|
|
"notify_bridge_core.notifications.telegram.client",
|
|
duration_minutes=15,
|
|
)
|
|
assert entry["baseline_level"] == "ERROR"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_diagnostic_twice_replaces_schedule(tmp_data_dir) -> None: # noqa: ARG001
|
|
"""Clicking the button twice extends, doesn't stack."""
|
|
from notify_bridge_server.services.diagnostic_mode import (
|
|
list_active, set_diagnostic,
|
|
)
|
|
|
|
_reset_state()
|
|
module = "notify_bridge_core"
|
|
await set_diagnostic(module, 5)
|
|
first_active = list_active()
|
|
assert len(first_active) == 1
|
|
first_expires = first_active[0]["expires_at"]
|
|
|
|
# Sleep just long enough to make the timestamps distinct, then re-set.
|
|
await asyncio.sleep(0.05)
|
|
await set_diagnostic(module, 60)
|
|
second_active = list_active()
|
|
assert len(second_active) == 1
|
|
assert second_active[0]["expires_at"] != first_expires
|
|
assert second_active[0]["remaining_seconds"] > 30 * 60
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_revert_restores_baseline(tmp_data_dir) -> None: # noqa: ARG001
|
|
from notify_bridge_server.services.diagnostic_mode import (
|
|
revert_diagnostic, set_diagnostic,
|
|
)
|
|
|
|
_reset_state()
|
|
module = "sqlalchemy.engine"
|
|
await set_diagnostic(module, 30)
|
|
assert logging.getLogger(module).level == logging.DEBUG
|
|
|
|
reverted = await revert_diagnostic(module)
|
|
assert reverted is True
|
|
# noisy-library default is WARNING (30)
|
|
assert logging.getLogger(module).level == logging.WARNING
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revert_reads_db_at_revert_time(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
|
|
"""Editing ``log_levels`` while the override is active is honored when
|
|
the revert fires — not the snapshot taken at activation time."""
|
|
from notify_bridge_server.services.diagnostic_mode import (
|
|
revert_diagnostic, set_diagnostic,
|
|
)
|
|
|
|
_reset_state()
|
|
module = "sqlalchemy.engine"
|
|
_patch_db_read(monkeypatch, "")
|
|
await set_diagnostic(module, 30)
|
|
|
|
# Operator edits the setting mid-window — bump to ERROR.
|
|
_patch_db_read(monkeypatch, "sqlalchemy.engine=ERROR")
|
|
|
|
assert await revert_diagnostic(module) is True
|
|
assert logging.getLogger(module).level == logging.ERROR
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_revert_no_active_returns_false(tmp_data_dir) -> None: # noqa: ARG001
|
|
from notify_bridge_server.services.diagnostic_mode import revert_diagnostic
|
|
|
|
_reset_state()
|
|
assert await revert_diagnostic("notify_bridge_core") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_revert_after_window_elapses(tmp_data_dir) -> None: # noqa: ARG001
|
|
"""The asyncio-timer fallback fires near ``expires_at`` and restores
|
|
the baseline. Uses a sub-second window so the test stays fast.
|
|
|
|
Bypasses ``set_diagnostic`` (which clamps to minutes) by populating the
|
|
``_active`` dict and calling ``_schedule_revert`` directly.
|
|
"""
|
|
from notify_bridge_server.services import diagnostic_mode as svc
|
|
|
|
_reset_state()
|
|
module = "sqlalchemy.engine"
|
|
baseline = svc._baseline_for(module, db_log_levels="")
|
|
now = datetime.now(timezone.utc)
|
|
expires = now + timedelta(seconds=0.3)
|
|
logging.getLogger(module).setLevel("DEBUG")
|
|
svc._active[module] = svc._Override(
|
|
module=module,
|
|
baseline_level=baseline,
|
|
activated_at=now,
|
|
expires_at=expires,
|
|
)
|
|
svc._schedule_revert(module, expires)
|
|
|
|
await asyncio.sleep(0.5)
|
|
|
|
assert module not in svc._active
|
|
assert logging.getLogger(module).level == logging.WARNING
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_task_retained_until_fire(tmp_data_dir) -> None: # noqa: ARG001
|
|
"""The asyncio fallback path must keep a strong reference to its task
|
|
so CPython doesn't GC it before the timer fires."""
|
|
from notify_bridge_server.services import diagnostic_mode as svc
|
|
|
|
_reset_state()
|
|
when = datetime.now(timezone.utc) + timedelta(seconds=10)
|
|
svc._schedule_revert("notify_bridge_core", when)
|
|
# The retainer set should hold exactly the task we just queued.
|
|
assert len(svc._bg_tasks) == 1
|
|
# Cancel it to clean up; the done-callback will drop it.
|
|
for task in list(svc._bg_tasks):
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
def test_list_active_omits_and_sweeps_expired(tmp_data_dir) -> None: # noqa: ARG001
|
|
"""Expired entries are filtered AND removed so a delayed scheduler
|
|
fire doesn't leave ghost rows in ``_active`` forever."""
|
|
from notify_bridge_server.services import diagnostic_mode as svc
|
|
|
|
_reset_state()
|
|
past = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
svc._active["sqlalchemy.engine"] = svc._Override(
|
|
module="sqlalchemy.engine",
|
|
baseline_level="WARNING",
|
|
activated_at=past - timedelta(minutes=30),
|
|
expires_at=past,
|
|
)
|
|
assert svc.list_active() == []
|
|
assert "sqlalchemy.engine" not in svc._active
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revert_all_clears_every_override(tmp_data_dir) -> None: # noqa: ARG001
|
|
from notify_bridge_server.services.diagnostic_mode import (
|
|
list_active, revert_all, set_diagnostic,
|
|
)
|
|
|
|
_reset_state()
|
|
await set_diagnostic("notify_bridge_core", 30)
|
|
await set_diagnostic("sqlalchemy.engine", 30)
|
|
assert len(list_active()) == 2
|
|
|
|
count = await revert_all()
|
|
assert count == 2
|
|
assert list_active() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API smoke — bypasses auth via dependency_overrides
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def _admin_client(tmp_data_dir): # noqa: ARG001
|
|
"""Yield a TestClient with ``require_admin`` short-circuited.
|
|
|
|
Keeps the auth-flow's SQLAlchemy/greenlet issues out of the picture
|
|
while still exercising the FastAPI router, path converters, and the
|
|
``HTTPException`` paths.
|
|
"""
|
|
_reset_state()
|
|
from notify_bridge_server.auth.dependencies import require_admin
|
|
from notify_bridge_server.database.models import User
|
|
from notify_bridge_server.main import app
|
|
|
|
fake = User(
|
|
id=1, username="admin",
|
|
password_hash="x", role="admin", token_version=0,
|
|
)
|
|
app.dependency_overrides[require_admin] = lambda: fake
|
|
|
|
with TestClient(app) as client:
|
|
yield client
|
|
|
|
app.dependency_overrides.pop(require_admin, None)
|
|
_reset_state()
|
|
|
|
|
|
def test_api_post_rejects_unlisted_module_with_400(_admin_client: TestClient) -> None:
|
|
resp = _admin_client.post(
|
|
"/api/settings/diagnostic-mode",
|
|
json={"module": "evil.namespace", "duration_minutes": 15},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "allowlist" in resp.json().get("detail", "")
|
|
|
|
|
|
def test_api_post_rejects_huge_duration_with_400(_admin_client: TestClient) -> None:
|
|
resp = _admin_client.post(
|
|
"/api/settings/diagnostic-mode",
|
|
json={"module": "notify_bridge_core", "duration_minutes": 99999},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_api_delete_unknown_returns_404(_admin_client: TestClient) -> None:
|
|
resp = _admin_client.delete(
|
|
"/api/settings/diagnostic-mode/notify_bridge_core",
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_api_delete_handles_dotted_module_path(_admin_client: TestClient) -> None:
|
|
"""``{module:path}`` lets dotted names survive URL routing intact."""
|
|
target = "notify_bridge_core.notifications.telegram.client"
|
|
_admin_client.post(
|
|
"/api/settings/diagnostic-mode",
|
|
json={"module": target, "duration_minutes": 15},
|
|
)
|
|
resp = _admin_client.delete(f"/api/settings/diagnostic-mode/{target}")
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["reverted"] == target
|