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>
This commit is contained in:
Indiana
2026-07-31 12:25:10 +00:00
parent 76af1939e6
commit 8187253331

View File

@@ -41,14 +41,38 @@ def _test_database_url() -> str:
TEST_DATABASE_URL = _test_database_url() TEST_DATABASE_URL = _test_database_url()
# Fail loudly rather than eating the live data if the guard above is ever
# defeated by an unusual URL shape. 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: if TEST_DATABASE_URL == settings.database_url:
raise RuntimeError( raise RuntimeError(
"refusing to run: the test database resolved to the production " "refusing to run: the test database resolved to the production "
"database, and the suite drops every table" "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; # 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 # 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 # connection from one test's loop breaks the next test. NullPool sidesteps