From 2f5372207811a470db44a320607e89cf9f0669f5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 20 Oct 2026 00:28:34 +0200 Subject: [PATCH] fix(tests): a failing database test no longer prints the password (T-12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest prints a failing test's fixture arguments via repr() as part of the default traceback header — no -v or --showlocals needed — so any test taking the test_database_url fixture and failing printed the real TEST_DATABASE_URL straight into the terminal, password included. test_database_url now returns a MaskedURL: a str subclass identical to a plain string for every functional use (passed to .replace(), urlsplit(), a subprocess environment) but whose repr() masks the password while still showing host and database name. __str__ is untouched, so every caller still connects with the real value. tests/test_conftest.py proves it, both for the fixture itself and for MaskedURL's string behaviour in isolation. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 4 ++++ tests/conftest.py | 19 ++++++++++++++++++- tests/test_conftest.py | 43 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/test_conftest.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d18b63..34fd1cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `tests/test_tenants_migration.py` now ends on `upgrade head` rather than its own revision, so a full suite run leaves the test database at head instead of one or two migrations behind it. +- **A failing database test no longer prints the test database password** (core-api + T-12) — the `test_database_url` fixture now returns a `MaskedURL`: identical to a + plain string for every functional use, but its `repr()` — which pytest prints for a + failing test's fixture arguments, without needing `-v` — masks the password. ## [1.15.0] - 2026-10-19 diff --git a/tests/conftest.py b/tests/conftest.py index 7747d91..dc47171 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,23 @@ def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess: return result +class MaskedURL(str): + """A database URL, fully usable as a plain string (passed to `.replace()`, + `urlsplit()`, a subprocess environment, ...) but never through `repr()` + (core-api T-12): pytest prints a failing test's fixture arguments using + `repr()`, with no `-v`/`--showlocals` needed to trigger it — that is how + a failing database test used to print this fixture's password straight + into the terminal. `__str__` is deliberately left alone, so the real + value is still what every caller actually connects with.""" + + def __repr__(self) -> str: + parts = urlsplit(self) + netloc = parts.netloc + if parts.password: + netloc = netloc.replace(f":{parts.password}@", ":***@") + return repr(parts._replace(netloc=netloc).geturl()) + + @pytest.fixture(scope="session") def test_database_url() -> str: """The core_api_test database URL, or skip/refuse per the rules above.""" @@ -62,7 +79,7 @@ def test_database_url() -> str: "TEST_DATABASE_URL resolves to 'core_api' (production) — refusing to use it for tests" ) - return url + return MaskedURL(url) def _grant_test_office_gemini(url: str) -> None: diff --git a/tests/test_conftest.py b/tests/test_conftest.py new file mode 100644 index 0000000..c8b0df0 --- /dev/null +++ b/tests/test_conftest.py @@ -0,0 +1,43 @@ +"""Tests for conftest.py's own test-infrastructure fixtures. + +core-api T-12: a failing database test used to print the test database +password, because pytest renders a failing test's fixture arguments with +`repr()` — no `-v` or `--showlocals` needed to trigger it, just a test that +takes `test_database_url` and fails. `MaskedURL` (conftest.py) is the fix: +same string, same connection, but a `repr()` that never shows the password. +""" +from urllib.parse import urlsplit + +from tests.conftest import MaskedURL + + +def test_database_url_fixture_renders_without_its_password(test_database_url): + """Mutation check: revert `MaskedURL.__repr__` to the inherited `str` + one (or hand back a plain `str` from the fixture) and this fails — the + real password would appear in `repr(test_database_url)`, exactly as it + did in a failing test's traceback before T-12.""" + password = urlsplit(test_database_url).password + assert password, "fixture must carry a real password for this test to mean anything" + + rendered = repr(test_database_url) + + assert password not in rendered + # Host and database name are still visible — only the password is masked. + assert "***" in rendered + assert urlsplit(test_database_url).path in rendered + + +def test_masked_url_is_still_a_fully_usable_string(): + """The wrapper changes only `repr()`; every other string operation the + rest of the suite relies on (`.replace()`, `urlsplit()`, use as a + subprocess environment value) must see the real value unchanged.""" + raw = "postgresql://core_api_test:s3cr3t@localhost:5432/core_api_test" + masked = MaskedURL(raw) + + assert masked == raw + assert str(masked) == raw + assert masked.replace("postgresql://", "postgresql+asyncpg://", 1) == raw.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) + assert urlsplit(masked).password == "s3cr3t" + assert "s3cr3t" not in repr(masked)