Files
core-api/tests/test_tenants.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

206 lines
8.3 KiB
Python

"""
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"