From 8187253331415a082875b62cb097b73c852ee3cd Mon Sep 17 00:00:00 2001 From: Indiana Date: Fri, 31 Jul 2026 12:25:10 +0000 Subject: [PATCH] test: make it structurally impossible to run tests against live data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/tests/conftest.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 54b1143..ebad9d8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -41,14 +41,38 @@ def _test_database_url() -> str: 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: 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