Files
core-api/tests/test_tenants_migration.py
T
jpmschweitzerandClaude Sonnet 5 99ee994023 feat(tenants): add the tenant registry, the one enforcement point Book and Scribe now share (T-8)
A tenant has been an unchecked string since T-4/T-7: the Office's
X-Office-Tenant header or a user JWT's tatlock_user claim, with Book and
Scribe each running their own _require_tenant that checked only presence.
A typo'd claim opened a fresh, empty namespace rather than being refused.

src/domains/tenants/ (controller/models/schemas/service) adds tenants and
tenant_consumers (migration 007, down_revision="006"): tenants.id is the
slug a tatlock tenant carries, kind (household|person|test) and status
(active|suspended) CHECK-constrained; tenant_consumers is the grant pairing
a consumer to a tenant. Migration 007 seeds the two tenants in use today —
jpmschweitzer (household, "Jeroen") and llm_tester (test, "Playground") —
each granted to office-claude, the only Office consumer configured in
Authentik. Every existing owner_tenant column (book_records,
book_tenant_counters, scribe_addresses, scribe_messages,
scribe_mailbox_entries — five, grepped and confirmed) now carries a
foreign key to tenants.id, ON DELETE RESTRICT, at both the migration and
the ORM model.

TenantsService.require_tenant is the one enforcement point: no tenant is
400 tenant_required (unchanged); an unknown tenant is 403 tenant_unknown;
a suspended one is 403 tenant_suspended; an Office caller whose consumer
holds no grant in that tenant is 403 consumer_not_granted. One query — a
LEFT JOIN against the grant — so presence, status and the grant are all
read in a single round trip. Book's and Scribe's own _require_tenant now
delegate to it, translating TenantError to their own exception type so
every call site and each controller's single except clause keep working
unchanged.

Read-only this round: GET /tenants/me answers the caller's own tenant and
its granted consumers (the cheap "am I allowed here" probe); GET /tenants
answers every tenant for a tatlock/scheduler caller, or a list of one for
a tenant-bearing caller — another tenant's existence is never revealed.
No write routes; provisioning a tenant is workspace T-195.

Tests: tests/test_tenants.py and tests/test_tenants_migration.py (new);
a TestTenantRegistryEnforcement class added to both tests/test_book.py and
tests/test_scribe.py proving all four cases (unknown, suspended,
ungranted-consumer, and the control) on a live Book and Scribe route, not
only on /tenants itself. tests/conftest.py gains a tenant_registry schema
fixture and a suspended_tenant fixture (inserted and deleted per test; no
FK references it); book_schema and scribe_schema each independently grant
a second, test-environment-only consumer (office-gemini) in both seeded
tenants, since test_book.py and test_scribe.py have used it as a stand-in
second consumer since before this grant check existed — the migration
itself still seeds only office-claude, matching the dossier.

Mutation-checked: dropping the grant check fails the three
consumer_not_granted tests (200 instead of 403); dropping the status
check fails the three tenant_suspended tests; making Book bypass the
helper fails its unknown-tenant test (200 instead of 403). All three
restored and the full suite re-verified green (508 + 35 = 543 passed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 17:30:55 +02:00

189 lines
7.8 KiB
Python

"""
Tests for the tenant registry migration 007 (core-api T-8, workspace D-73).
Runs against `core_api_test` only (see conftest.py — skip when
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
Self-contained: starts and ends at head regardless of what ran before it,
so test order never matters for the schema the rest of the suite needs.
"""
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from tests.conftest import run_alembic
async def _table_exists(url: str, table: str) -> bool:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT to_regclass(:name) IS NOT NULL AS present"),
{"name": table},
)
return bool(result.scalar())
finally:
await engine.dispose()
async def _scalar(url: str, query: str, **params) -> object:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
result = await conn.execute(text(query), params)
return result.scalar()
finally:
await engine.dispose()
class TestMigration007RoundTrip:
"""Migration 007 must upgrade and downgrade cleanly (dossier verification #2)."""
@pytest.mark.asyncio
async def test_upgrade_creates_tenant_tables(self, test_database_url):
result = run_alembic(test_database_url, "upgrade", "head")
assert result.returncode == 0, result.stderr
for table in ("tenants", "tenant_consumers"):
assert await _table_exists(test_database_url, table), f"{table} missing after upgrade"
@pytest.mark.asyncio
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
baseline = run_alembic(test_database_url, "upgrade", "head")
assert baseline.returncode == 0, baseline.stderr
down = run_alembic(test_database_url, "downgrade", "006")
assert down.returncode == 0, down.stderr
assert not await _table_exists(test_database_url, "tenants")
assert not await _table_exists(test_database_url, "tenant_consumers")
# Book and Scribe tables, created by migrations below this one, must
# survive a downgrade of 007 — proves 007 only touches its own
# tables (and the FKs it added onto theirs).
assert await _table_exists(test_database_url, "book_records")
assert await _table_exists(test_database_url, "scribe_addresses")
# Leave the schema at head for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "007")
assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "tenants")
assert await _table_exists(test_database_url, "tenant_consumers")
class TestMigration007Checks:
"""The CHECK constraints on `tenants.kind` and `tenants.status`."""
@pytest.mark.asyncio
async def test_kind_check_constraint_exists(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
found = await _scalar(
test_database_url,
"SELECT conname FROM pg_constraint WHERE conname = 'ck_tenants_kind'",
)
assert found == "ck_tenants_kind"
@pytest.mark.asyncio
async def test_status_check_constraint_exists(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
found = await _scalar(
test_database_url,
"SELECT conname FROM pg_constraint WHERE conname = 'ck_tenants_status'",
)
assert found == "ck_tenants_status"
@pytest.mark.asyncio
async def test_kind_check_rejects_an_invalid_kind(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
with pytest.raises(IntegrityError):
await conn.execute(
text(
"INSERT INTO tenants (id, display_name, kind, status) "
"VALUES ('bad-kind-tenant', 'x', 'nonsense', 'active')"
)
)
await conn.commit()
finally:
await engine.dispose()
class TestMigration007Seed:
"""core-api T-8: the registry seeds exactly the tenants/grants the
dossier specifies — not a third, and not a second grant."""
@pytest.mark.asyncio
async def test_seeds_jpmschweitzer_and_llm_tester(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
for tenant_id, display_name, kind in (
("jpmschweitzer", "Jeroen", "household"),
("llm_tester", "Playground", "test"),
):
row_kind = await _scalar(
test_database_url,
"SELECT kind FROM tenants WHERE id = :id AND display_name = :name AND status = 'active'",
id=tenant_id,
name=display_name,
)
assert row_kind == kind, f"{tenant_id} seed row missing or wrong"
@pytest.mark.asyncio
async def test_both_tenants_grant_only_office_claude(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
for tenant_id in ("jpmschweitzer", "llm_tester"):
found = await _scalar(
test_database_url,
"SELECT consumer FROM tenant_consumers WHERE tenant_id = :id AND consumer = 'office-claude'",
id=tenant_id,
)
assert found == "office-claude", f"{tenant_id} is missing its office-claude grant"
class TestMigration007ForeignKeys:
"""Every owner_tenant column (five of them, per the dossier) now carries
an FK to tenants.id; an unknown owner_tenant is refused at the database,
not only in the service layer (dossier verification #5)."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"fk_name",
[
"fk_book_records_owner_tenant",
"fk_book_tenant_counters_owner_tenant",
"fk_scribe_addresses_owner_tenant",
"fk_scribe_messages_owner_tenant",
"fk_scribe_mailbox_entries_owner_tenant",
],
)
async def test_owner_tenant_fk_exists(self, test_database_url, fk_name):
run_alembic(test_database_url, "upgrade", "head")
found = await _scalar(
test_database_url,
"SELECT conname FROM pg_constraint WHERE conname = :name AND contype = 'f'",
name=fk_name,
)
assert found == fk_name
@pytest.mark.asyncio
async def test_fk_refuses_a_scribe_address_with_an_unknown_owner_tenant(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
with pytest.raises(IntegrityError):
await conn.execute(
text(
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
"VALUES ('no-such-tenant', 'fk-probe', 'test-harness')"
)
)
await conn.commit()
finally:
await engine.dispose()