Files
qtalker---/backend/tests/conftest.py
Indiana 8187253331 test: make it structurally impossible to run tests against live data
The suite drop_all()s every table before every test, and this box both
serves the live app and holds the repo — so "just don't run tests in prod"
is not a workable guard. Getting this wrong once already cost a production
Codex.

The existing check compared TEST_DATABASE_URL against settings.database_url.
That has a real hole: two different spellings of the SAME database —
`...@localhost/quantumancy` versus `...@127.0.0.1/quantumancy` — are
different strings, so the comparison passes and every table is dropped.

Added a second, name-based guard: the test database NAME must end in
`_test`. That cannot be defeated by how the host is spelled, and it also
catches a TEST_DATABASE_URL somebody set by hand to something live.

Both proven by attacking them:
- TEST_DATABASE_URL forced to the production URL -> refuses (guard 1).
- Same database reached via 127.0.0.1 instead of localhost -> refuses
  (guard 2; guard 1 alone would have allowed this and wiped it).
- A normal run still works: 43 tests pass and the live account that
  prompted this check is untouched afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:25:10 +00:00

130 lines
4.9 KiB
Python

import os
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.db import Base, get_db
from app.main import app
def _test_database_url() -> str:
"""A SEPARATE database from the configured one.
This is not a nicety. The autouse fixture below drop_all()s every table
before each test, so pointing it at settings.database_url means running
the suite silently annihilates the live install — every account, every
discovered spirit, every Ghost Log. That is exactly what used to happen:
a full test run left the production Codex at zero entities.
Honouring TEST_DATABASE_URL when set (for CI), and otherwise deriving
`<configured-db>_test`, so the suite can never touch real data even if
somebody runs it on the production host — which is the normal case here,
since this box both serves the app and holds the repo.
"""
explicit = os.environ.get("TEST_DATABASE_URL")
if explicit:
return explicit
base, _, name = settings.database_url.rpartition("/")
if not name:
raise RuntimeError(
"cannot derive a test database from DATABASE_URL; "
"set TEST_DATABASE_URL explicitly"
)
# Strip any query string (e.g. ?ssl=require) before suffixing the name.
db_name, sep, query = name.partition("?")
return f"{base}/{db_name}_test{sep}{query}"
TEST_DATABASE_URL = _test_database_url()
def _database_name(url: str) -> str:
"""Bare database name from a SQLAlchemy URL, sans query string."""
return url.rpartition("/")[2].partition("?")[0]
# Two independent guards, because the suite drop_all()s every table before
# every single test and this box both serves the live app and holds the repo.
# Getting this wrong once already cost a production Codex.
#
# 1. Not literally the configured URL.
if TEST_DATABASE_URL == settings.database_url:
raise RuntimeError(
"refusing to run: the test database resolved to the production "
"database, and the suite drops every table"
)
# 2. The database NAME must end in `_test`. A pure URL comparison is not
# enough — `postgresql+asyncpg://u:p@localhost/quantumancy` and
# `...@127.0.0.1/quantumancy` are different strings addressing the same
# database, so guard #1 alone would happily wipe it. Requiring the name
# itself to be a test database cannot be defeated by how the host is
# spelled, and it also catches a TEST_DATABASE_URL that was set by hand
# to something live.
_test_db_name = _database_name(TEST_DATABASE_URL)
if not _test_db_name.endswith("_test"):
raise RuntimeError(
f"refusing to run: test database {_test_db_name!r} does not end in "
"'_test'. The suite drops every table; it must never be pointed at a "
"database that could hold real seekers, spirits or Ghost Logs."
)
# pytest-asyncio gives each test function its own event loop by default;
# asyncpg connections are bound to the loop they were opened on, so a pooled
# connection from one test's loop breaks the next test. NullPool sidesteps
# this by opening a fresh connection per checkout — scoped to this dedicated
# test engine so app.db.engine (used by production) is unaffected.
test_engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool)
TestSessionLocal = async_sessionmaker(test_engine, expire_on_commit=False)
@pytest_asyncio.fixture(autouse=True)
async def _reset_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async def _override_get_db():
async with TestSessionLocal() as session:
yield session
app.dependency_overrides[get_db] = _override_get_db
@pytest_asyncio.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="https://test") as ac:
yield ac
@pytest.fixture
def sync_client(monkeypatch):
"""Sync TestClient (supports WebSocket tests). The WS handler's session
maker is swapped to the NullPool test engine so its writes land in the
same test database the async fixtures see. The lifespan's engine is also
swapped: TestClient runs the lifespan on its own portal loop, and the
pooled production engine would carry connections across loops."""
import app.main as main_module
import app.routes.device as device_module
import app.ws as ws_module
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(device_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(main_module, "engine", test_engine)
with TestClient(app, base_url="https://testserver") as tc:
yield tc
@pytest_asyncio.fixture
async def db_session():
async with TestSessionLocal() as session:
yield session