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>
This commit is contained in:
2026-10-19 17:30:55 +02:00
co-authored by Claude Sonnet 5
parent e02173aefb
commit 99ee994023
20 changed files with 1105 additions and 37 deletions
+126 -3
View File
@@ -18,6 +18,7 @@ environment and a clean cache; overriding `POSTGRES_HOST`/`USER`/
`PASSWORD`/`DATABASE` in that child's environment, derived from
`TEST_DATABASE_URL`, is what actually redirects the migration.
"""
import asyncio
import os
import subprocess
from pathlib import Path
@@ -76,12 +77,80 @@ def test_database_url() -> str:
return 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 book_schema(test_database_url: str) -> str:
"""Ensure migration 005 (and everything before it) is applied to core_api_test."""
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
@@ -125,10 +194,12 @@ async def clean_book_tables(book_session):
@pytest.fixture(scope="session")
def scribe_schema(test_database_url: str) -> str:
"""Ensure migration 006 (and everything before it) is applied to core_api_test."""
"""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
@@ -164,3 +235,55 @@ async def clean_scribe_tables(scribe_session):
)
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()
+34
View File
@@ -721,3 +721,37 @@ class TestUserCallerTenantRequired:
assert response.status_code == 201
assert response.json()["created_by"] == "jeroen"
assert response.json()["number"] == 1
class TestTenantRegistryEnforcement:
"""core-api T-8 / workspace D-73: Book's own `_require_tenant` now
delegates to the tenant registry's one enforcement point, so a Book
route gains three refusal paths it did not have before, alongside the
control that a properly granted, active tenant still succeeds."""
def test_unknown_tenant_is_403(self, client):
response = client.get(
"/book/records", headers=office_headers(tenant="no-such-tenant")
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_unknown"
def test_suspended_tenant_is_403(self, client, suspended_tenant):
response = client.get(
"/book/records", headers=office_headers(tenant=suspended_tenant)
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_suspended"
def test_consumer_without_a_grant_is_403(self, client):
response = client.get(
"/book/records", headers=office_headers(consumer="office-ungranted")
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "consumer_not_granted"
def test_granted_consumer_in_an_active_tenant_succeeds(self, client):
"""The control: office-claude, granted in jpmschweitzer (active),
still reaches the route."""
response = client.get("/book/records", headers=office_headers())
assert response.status_code == 200
+39
View File
@@ -579,3 +579,42 @@ class TestTenantIsolation:
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["body"] == "shared-search-term in TENANT"
class TestTenantRegistryEnforcement:
"""core-api T-8 / workspace D-73: Scribe's own `_require_tenant` now
delegates to the tenant registry's one enforcement point, so a Scribe
route gains three refusal paths it did not have before, alongside the
control that a properly granted, active tenant still succeeds."""
def test_unknown_tenant_is_403(self, client):
response = client.get(
"/scribe/addresses",
headers=office_headers("jeroen", "office-claude", tenant="no-such-tenant"),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_unknown"
def test_suspended_tenant_is_403(self, client, suspended_tenant):
response = client.get(
"/scribe/addresses",
headers=office_headers("jeroen", "office-claude", tenant=suspended_tenant),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_suspended"
def test_consumer_without_a_grant_is_403(self, client):
response = client.get(
"/scribe/addresses",
headers=office_headers("jeroen", "office-ungranted", tenant=TENANT),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "consumer_not_granted"
def test_granted_consumer_in_an_active_tenant_succeeds(self, client):
"""The control: office-claude, granted in jpmschweitzer (active),
still reaches the route."""
response = client.get(
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=TENANT)
)
assert response.status_code == 200
+205
View File
@@ -0,0 +1,205 @@
"""
Tests for the tenant registry's read API (core-api T-8 / workspace D-73)
against `core_api_test`: `GET /tenants/me` and `GET /tenants`.
Follows tests/test_scribe.py's TestClient pattern: `get_async_session` is
overridden to the test database for every test, building a fresh
engine/session per call because TestClient drives the ASGI app on a
different event loop than pytest-asyncio's fixture setup.
"""
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from src.domains.auth.callers import _TENANT_RE
from src.domains.tenants.models import TENANT_ID_PATTERN
from src.main import app
from src.shared.config import get_settings
from src.shared.database import get_async_session
settings = get_settings()
OFFICE_KEY = "test-office-key"
TATLOCK_KEY = "test-tatlock-key"
SCHEDULER_KEY = "test-scheduler-key"
TENANT = "jpmschweitzer"
OTHER_TENANT = "llm_tester"
def test_tenant_id_pattern_matches_callers_tenant_regex():
"""models.py deliberately duplicates callers.py's `_TENANT_RE` shape
rather than importing its private constant (see models.py's comment) —
assert the two never drift apart instead."""
assert TENANT_ID_PATTERN == _TENANT_RE.pattern
@pytest.fixture(autouse=True)
def service_keys():
"""Every test here authenticates with one of the three static keys
unless it says otherwise."""
originals = (
settings.office_service_key,
settings.tatlock_service_key,
settings.scheduler_service_key,
)
settings.office_service_key = OFFICE_KEY
settings.tatlock_service_key = TATLOCK_KEY
settings.scheduler_service_key = SCHEDULER_KEY
yield
(
settings.office_service_key,
settings.tatlock_service_key,
settings.scheduler_service_key,
) = originals
@pytest.fixture
def client(tenant_registry, test_database_url):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
async def _override():
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 engine.dispose()
app.dependency_overrides[get_async_session] = _override
yield TestClient(app)
app.dependency_overrides.pop(get_async_session, None)
def office_headers(
identity: str = "jeroen", consumer: str = "office-claude", tenant: str = TENANT
) -> dict:
return {
"Authorization": f"Bearer {OFFICE_KEY}",
"X-Office-Identity": identity,
"X-Office-Consumer": consumer,
"X-Office-Tenant": tenant,
}
def tatlock_headers() -> dict:
return {"Authorization": f"Bearer {TATLOCK_KEY}"}
def scheduler_headers() -> dict:
return {"Authorization": f"Bearer {SCHEDULER_KEY}"}
class TestMe:
"""`GET /tenants/me` — the cheap "am I allowed here" probe (D-73)."""
def test_office_caller_sees_its_own_tenant_and_its_grants(self, client):
response = client.get("/tenants/me", headers=office_headers())
assert response.status_code == 200
body = response.json()
assert body["id"] == TENANT
assert body["display_name"] == "Jeroen"
assert body["kind"] == "household"
assert body["status"] == "active"
# office-claude is the grant the dossier seeds; office-gemini is a
# test-environment-only addition (tests/conftest.py's
# tenant_registry fixture) — assert membership, not the exact list,
# so this test is not coupled to that test-only extra.
assert "office-claude" in body["consumers"]
def test_other_seeded_tenant_answers_its_own_identity(self, client):
response = client.get("/tenants/me", headers=office_headers(tenant=OTHER_TENANT))
assert response.status_code == 200
body = response.json()
assert body["id"] == OTHER_TENANT
assert body["kind"] == "test"
def test_unknown_tenant_is_403(self, client):
response = client.get(
"/tenants/me", headers=office_headers(tenant="no-such-tenant")
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_unknown"
def test_suspended_tenant_is_403(self, client, suspended_tenant):
response = client.get(
"/tenants/me", headers=office_headers(tenant=suspended_tenant)
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_suspended"
def test_consumer_without_a_grant_is_403(self, client):
response = client.get(
"/tenants/me", headers=office_headers(consumer="office-ungranted")
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "consumer_not_granted"
def test_tatlock_caller_is_403_forbidden(self, client):
"""`/me` is Office/user-scoped (`require_tenants_reader`), the same
reader class as Book and Scribe — tatlock has no "own tenant" to
answer with, so it is refused at the caller-kind check, before the
service ever runs, rather than reaching a tenant_required 400."""
response = client.get("/tenants/me", headers=tatlock_headers())
assert response.status_code == 403
assert response.json()["detail"]["code"] == "forbidden"
def test_user_without_tenant_claim_is_400(self, client):
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
response = client.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
assert response.status_code == 400
assert response.json()["detail"]["code"] == "tenant_required"
def test_user_with_tenant_claim_succeeds(self, client):
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
mock_get_current_user.return_value = {
"preferred_username": "jeroen",
"tatlock_user": TENANT,
}
response = client.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
assert response.status_code == 200
assert response.json()["id"] == TENANT
class TestList:
"""`GET /tenants` — the hard boundary: another tenant's existence is
never revealed to a tenant-bearing caller."""
def test_tatlock_sees_every_tenant(self, client):
response = client.get("/tenants", headers=tatlock_headers())
assert response.status_code == 200
ids = {t["id"] for t in response.json()["tenants"]}
assert {TENANT, OTHER_TENANT}.issubset(ids)
def test_scheduler_sees_every_tenant(self, client):
response = client.get("/tenants", headers=scheduler_headers())
assert response.status_code == 200
ids = {t["id"] for t in response.json()["tenants"]}
assert {TENANT, OTHER_TENANT}.issubset(ids)
def test_office_caller_sees_only_its_own_tenant_as_a_list_of_one(self, client):
response = client.get("/tenants", headers=office_headers(tenant=TENANT))
assert response.status_code == 200
tenants = response.json()["tenants"]
assert len(tenants) == 1
assert tenants[0]["id"] == TENANT
# The hard boundary (D-73): the other seeded tenant's existence is
# not revealed, not even as an id in a list it is excluded from.
assert all(t["id"] != OTHER_TENANT for t in tenants)
def test_other_tenant_caller_sees_only_its_own_as_a_list_of_one(self, client):
response = client.get("/tenants", headers=office_headers(tenant=OTHER_TENANT))
assert response.status_code == 200
tenants = response.json()["tenants"]
assert len(tenants) == 1
assert tenants[0]["id"] == OTHER_TENANT
def test_user_without_tenant_claim_is_400(self, client):
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
response = client.get("/tenants", headers={"Authorization": "Bearer some-jwt"})
assert response.status_code == 400
assert response.json()["detail"]["code"] == "tenant_required"
+188
View File
@@ -0,0 +1,188 @@
"""
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()