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>
377 lines
16 KiB
Python
377 lines
16 KiB
Python
"""
|
|
Shared test fixtures.
|
|
|
|
Database-backed tests need `TEST_DATABASE_URL` (core-api's gitignored .env).
|
|
They **skip** when it is unset and **refuse** (hard failure, not a skip)
|
|
when it points at `core_api` — that is production, and the dev `.env`'s
|
|
other `POSTGRES_*` values point there too, so this is the one guard that
|
|
stands between a test run and the production database.
|
|
|
|
Running the actual migration against the test database is done through the
|
|
`alembic` CLI in a subprocess rather than in-process, because `alembic/env.py`
|
|
builds its connection in a fresh interpreter each time, unaffected by this
|
|
process's `lru_cache`d `get_settings()` — by the time a test runs, other test
|
|
modules have almost certainly already imported `src.main` and cached a
|
|
Settings instance pointed at production. `run_alembic` always passes
|
|
`-x target=test` (core-api T-3: alembic refuses to run with no target, or
|
|
any other one) and sets `TEST_DATABASE_URL` in the child's environment to
|
|
`url`, which is what actually redirects the migration — the subprocess's own
|
|
`alembic/env.py` reads that variable itself; nothing here overrides
|
|
`POSTGRES_*` any more.
|
|
"""
|
|
import asyncio
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
|
|
import pytest
|
|
from dotenv import load_dotenv
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
ALEMBIC_BIN = REPO_ROOT / ".venv" / "bin" / "alembic"
|
|
|
|
load_dotenv(REPO_ROOT / ".env")
|
|
|
|
|
|
def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
|
|
"""Run `alembic -x target=test <args>` against `url`, never against the
|
|
repo's own .env database."""
|
|
env = os.environ.copy()
|
|
env["TEST_DATABASE_URL"] = url
|
|
result = subprocess.run(
|
|
[str(ALEMBIC_BIN), "-x", "target=test", *args],
|
|
cwd=str(REPO_ROOT),
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
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."""
|
|
url = os.environ.get("TEST_DATABASE_URL")
|
|
if not url:
|
|
pytest.skip("TEST_DATABASE_URL is not set — database tests skipped")
|
|
|
|
db_name = urlsplit(url).path.lstrip("/")
|
|
if db_name == "core_api":
|
|
pytest.fail(
|
|
"TEST_DATABASE_URL resolves to 'core_api' (production) — refusing to use it for tests"
|
|
)
|
|
|
|
return MaskedURL(url)
|
|
|
|
|
|
def _grant_test_office_gemini(url: str) -> None:
|
|
"""
|
|
Grant a second, test-environment-only consumer, `office-gemini`, in
|
|
both seeded tenants: test_book.py and test_scribe.py have used
|
|
`office-gemini` as a stand-in second Office consumer since before
|
|
core-api T-8's grant check existed, to prove behaviour that depends on
|
|
two *distinct* consumers (address ownership across consumers,
|
|
`book_events.client` recording the actual caller) rather than on any
|
|
one consumer being granted. Migration 007 itself seeds only
|
|
`office-claude` — the only consumer actually configured in Authentik
|
|
today (T-8's dossier) — so this grant is inserted directly here,
|
|
test-only, rather than through the migration; production's seed stays
|
|
exactly what the dossier specifies. A consumer this never grants
|
|
(e.g. `office-ungranted` in a test) is what proves the
|
|
`consumer_not_granted` refusal — this must never widen to cover one of
|
|
those.
|
|
|
|
A plain function, not a fixture: called from each of `book_schema`,
|
|
`scribe_schema` and `tenant_registry` right after their own `run_alembic
|
|
upgrade head`, so the grant is restored whenever any one of them is the
|
|
first to re-establish the schema in a session — the same
|
|
self-healing-on-first-use property those fixtures already rely on to
|
|
survive a migration test downgrading the schema mid-suite. A session-
|
|
scoped fixture shared between them would cache the *string* across that
|
|
downgrade while the real schema moved on underneath it — which is what
|
|
a first version of this did, and `tests/test_scribe.py` failing with
|
|
"relation tenants does not exist" is what caught it.
|
|
"""
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
|
|
async def _grant() -> None:
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO tenant_consumers (tenant_id, consumer) VALUES "
|
|
"('jpmschweitzer', 'office-gemini'), ('llm_tester', 'office-gemini') "
|
|
"ON CONFLICT DO NOTHING"
|
|
)
|
|
)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
asyncio.run(_grant())
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def tenant_registry(test_database_url: str) -> str:
|
|
"""Ensure migration 007 (and everything before it) is applied to
|
|
core_api_test, with its seed tenants/grants plus the test-only
|
|
office-gemini grant (see `_grant_test_office_gemini`). Used directly by
|
|
tests/test_tenants.py; book_schema and scribe_schema do the same thing
|
|
independently rather than depending on this, so each keeps its own
|
|
self-healing-on-first-use property (see that function's docstring)."""
|
|
result = run_alembic(test_database_url, "upgrade", "head")
|
|
if result.returncode != 0:
|
|
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
|
_grant_test_office_gemini(test_database_url)
|
|
return test_database_url
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def book_schema(test_database_url: str) -> str:
|
|
"""Ensure migration 005 (and everything before it, now including the
|
|
tenant registry) is applied to core_api_test."""
|
|
result = run_alembic(test_database_url, "upgrade", "head")
|
|
if result.returncode != 0:
|
|
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
|
_grant_test_office_gemini(test_database_url)
|
|
return test_database_url
|
|
|
|
|
|
@pytest.fixture
|
|
async def book_session(book_schema: str):
|
|
"""An AsyncSession bound to core_api_test, rolled back after each test."""
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
asyncpg_url = book_schema.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
|
|
async with session_factory() as session:
|
|
yield session
|
|
await session.rollback()
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def clean_book_tables(book_session):
|
|
"""
|
|
Book service methods commit internally (same pattern as the Dashboard
|
|
service), so `book_session`'s post-test rollback never runs against
|
|
anything — the data is already durable. TRUNCATE before each test
|
|
instead, which the append-only trigger does not block: it is a
|
|
row-level BEFORE UPDATE OR DELETE trigger, and TRUNCATE fires neither.
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
await book_session.execute(
|
|
text(
|
|
"TRUNCATE book_events, book_attachments, book_record_links, book_tags, "
|
|
"book_records, book_tenant_counters"
|
|
)
|
|
)
|
|
await book_session.commit()
|
|
yield book_session
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def scribe_schema(test_database_url: str) -> str:
|
|
"""Ensure migration 006 (and everything before it, now including the
|
|
tenant registry) is applied to core_api_test."""
|
|
result = run_alembic(test_database_url, "upgrade", "head")
|
|
if result.returncode != 0:
|
|
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
|
_grant_test_office_gemini(test_database_url)
|
|
return test_database_url
|
|
|
|
|
|
@pytest.fixture
|
|
async def scribe_session(scribe_schema: str):
|
|
"""An AsyncSession bound to core_api_test — same per-test engine pattern
|
|
as book_session (see its docstring for why: TestClient drives the app
|
|
on a different event loop than pytest-asyncio's fixture setup)."""
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
asyncpg_url = scribe_schema.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
|
|
async with session_factory() as session:
|
|
yield session
|
|
await session.rollback()
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def clean_scribe_tables(scribe_session):
|
|
"""Scribe service methods commit internally (same reason as Book's
|
|
clean_book_tables). TRUNCATE all tables together so Postgres resolves
|
|
the FK order (mailbox_entries -> messages, messages' self-referencing
|
|
reply_to) across the one statement.
|
|
|
|
`tenant_consumers` joined the list for migration 008 (core-api T-11):
|
|
its new composite FK `(tenant_id, default_address)` ->
|
|
`scribe_addresses(owner_tenant, name)` means Postgres now refuses to
|
|
truncate `scribe_addresses` alone — "cannot truncate a table
|
|
referenced in a foreign key constraint" — a restriction on the
|
|
constraint's existence, not on whether any row's `default_address` is
|
|
actually non-null (it never is here; migration 008's data step is a
|
|
no-op in `core_api_test`). `TRUNCATE ... CASCADE` was rejected: it
|
|
would silently drop every grant rather than only addresses, so the
|
|
four grants this file's tests depend on — both seeded tenants x
|
|
office-claude/office-gemini, the same pairing `_grant_test_office_gemini`
|
|
establishes once per session — are reinserted immediately after."""
|
|
from sqlalchemy import text
|
|
|
|
await scribe_session.execute(
|
|
text(
|
|
"TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses, "
|
|
"tenant_consumers"
|
|
)
|
|
)
|
|
await scribe_session.execute(
|
|
text(
|
|
"INSERT INTO tenant_consumers (tenant_id, consumer) VALUES "
|
|
"('jpmschweitzer', 'office-claude'), ('llm_tester', 'office-claude'), "
|
|
"('jpmschweitzer', 'office-gemini'), ('llm_tester', 'office-gemini')"
|
|
)
|
|
)
|
|
await scribe_session.commit()
|
|
yield scribe_session
|
|
|
|
|
|
@pytest.fixture
|
|
async def suspended_tenant(test_database_url: str):
|
|
"""
|
|
A third tenant, inserted with `status='suspended'`, for proving the
|
|
`tenant_suspended` refusal (core-api T-8) without touching either
|
|
seeded tenant that the rest of the suite depends on being active.
|
|
Nothing else ever references this id, so inserting and deleting it
|
|
per test is safe — no FK points at it.
|
|
|
|
Depends on `test_database_url` and runs its own `alembic upgrade head`
|
|
rather than on `tenant_registry`/`book_schema`/`scribe_schema`: this
|
|
fixture is reused from test_book.py, test_scribe.py and test_tenants.py,
|
|
so whichever of those files runs first would permanently cache
|
|
*any* session-scoped schema fixture it depended on — and a migration
|
|
test downgrading the schema afterwards (test_book_migration.py,
|
|
test_scribe_migration.py) would leave that cache stale for the other
|
|
two files. Self-contained and function-scoped avoids it outright, at
|
|
the cost of a redundant `alembic upgrade head` call (cheap relative to
|
|
the many such calls the migration test files already make).
|
|
"""
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
result = run_alembic(test_database_url, "upgrade", "head")
|
|
if result.returncode != 0:
|
|
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
|
|
|
tenant_id = "suspended-tenant"
|
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
|
|
async with session_factory() as session:
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO tenants (id, display_name, kind, status) "
|
|
"VALUES (:id, 'Suspended Test Tenant', 'test', 'suspended') "
|
|
"ON CONFLICT (id) DO UPDATE SET status = 'suspended'"
|
|
),
|
|
{"id": tenant_id},
|
|
)
|
|
await session.commit()
|
|
|
|
yield tenant_id
|
|
|
|
async with session_factory() as session:
|
|
await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
|
|
await session.commit()
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def default_address_grant(tenant_registry: str, test_database_url: str):
|
|
"""
|
|
Give `jpmschweitzer`/`office-claude` a default address (core-api T-11):
|
|
registers a throwaway `scribe_addresses` row first — migration 008's
|
|
composite FK means the grant's default must name a real address in the
|
|
same tenant — then points the grant at it. Reversed on teardown (default
|
|
back to null, the address row deleted) so the rest of the suite sees the
|
|
null default that `core_api_test`'s own copy of migration 008 actually
|
|
leaves this grant with (see test_tenants_migration.py's
|
|
TestMigration008DataStep: claude-ai is never seeded here).
|
|
"""
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
tenant_id = "jpmschweitzer"
|
|
consumer = "office-claude"
|
|
address = "test-default-address"
|
|
|
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
|
|
async with session_factory() as session:
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
|
|
"VALUES (:tenant, :address, 'test-harness')"
|
|
),
|
|
{"tenant": tenant_id, "address": address},
|
|
)
|
|
await session.execute(
|
|
text(
|
|
"UPDATE tenant_consumers SET default_address = :address "
|
|
"WHERE tenant_id = :tenant AND consumer = :consumer"
|
|
),
|
|
{"tenant": tenant_id, "address": address, "consumer": consumer},
|
|
)
|
|
await session.commit()
|
|
|
|
yield address
|
|
|
|
async with session_factory() as session:
|
|
await session.execute(
|
|
text(
|
|
"UPDATE tenant_consumers SET default_address = NULL "
|
|
"WHERE tenant_id = :tenant AND consumer = :consumer"
|
|
),
|
|
{"tenant": tenant_id, "consumer": consumer},
|
|
)
|
|
await session.execute(
|
|
text("DELETE FROM scribe_addresses WHERE owner_tenant = :tenant AND name = :address"),
|
|
{"tenant": tenant_id, "address": address},
|
|
)
|
|
await session.commit()
|
|
await engine.dispose()
|