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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user