fix(tests): a failing database test no longer prints the password (T-12)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+18
-1
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user