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:
+126
-3
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user