Files
core-api/tests/test_tenants.py
T
jpmschweitzerandClaude Sonnet 5 80034bbacf feat(tenants): add a default address on each consumer grant (T-11)
claude.ai's connector keeps no MCP session between tool calls, so the
Office cannot remember which address a conversation chose. Each consumer
grant now carries a fallback: the Office uses it when a call names none
and has no session binding (tatlock-office T-5, in a parallel round,
reads it).

Migration 008 (down_revision="007") adds
tenant_consumers.default_address — nullable varchar(63) — and a
composite foreign key (tenant_id, default_address) ->
scribe_addresses(owner_tenant, name), so a default always names a real
address in the same tenant. scribe_addresses already carries both a
primary key and a named unique constraint on (owner_tenant, name)
(migration 006), so the composite FK has something to bind to; Postgres's
default MATCH SIMPLE means a NULL default_address never triggers the
check, which is the state every grant but one is left in. The data step
sets jpmschweitzer/office-claude -> claude-ai only if that address exists
at upgrade time (true in production, false in core_api_test, where no
scribe_addresses are seeded) — an UPDATE ... WHERE EXISTS, not a bare
UPDATE, so it is a no-op rather than a failure when absent.
llm_tester/office-claude is untouched and stays null. Downgrade drops the
FK then the column.

TenantConsumer (models.py) gains the column and a matching
ForeignKeyConstraint. TenantsService.get_me now returns a third element,
the caller's own consumer's default_address (looked up only when
caller.consumer is not None — a user-kind caller carries no consumer, so
there is no grant row to hold a default for it) or None. GET /tenants/me
answers it; GET /tenants and its TenantSummary schema are unchanged.

Tests: TestMigration008RoundTrip/ForeignKey/DataStep added to
tests/test_tenants_migration.py (round-trip 007<->008, the FK existing
and refusing an unknown address while allowing null, the data step's
no-op and positive cases). tests/conftest.py gains a
default_address_grant fixture (registers a throwaway address, points a
grant at it, reverses both on teardown). tests/test_tenants.py gains
TestMeDefaultAddress (an office caller with/without a default, a user
caller always null).

Friction found and fixed in the same commit: the new composite FK made
Postgres refuse tests/test_scribe.py's existing clean_scribe_tables
TRUNCATE ("cannot truncate a table referenced in a foreign key
constraint") — the restriction is on the constraint's existence, not on
whether any row's default_address is actually non-null. tenant_consumers
joins that TRUNCATE; CASCADE was rejected because it would silently drop
every grant rather than only addresses, so the four grants
(jpmschweitzer/llm_tester x office-claude/office-gemini) are reinserted
in the same fixture, right after.

Mutation-checked: forcing default_address to null in the controller
fails exactly test_office_caller_with_a_default_sees_it, leaving the
without-a-default and user-caller tests green. Disabling the FK's
creation (and, symmetrically, its drop in downgrade — to keep the
round-trip self-consistent against the live core_api_test schema) fails
test_fk_exists and test_fk_refuses_a_default_naming_an_address_that_does_not_exist,
leaving test_fk_allows_a_null_default_regardless_of_tenant green. Both
mutations reverted; the full suite re-verified green (543 + 11 = 554
passed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 22:05:46 +02:00

235 lines
9.7 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 TestMeDefaultAddress:
"""`GET /tenants/me`'s `default_address` (core-api T-11): the Office's
fallback for a caller that names no address and holds no session
binding — null unless the grant itself has one."""
def test_office_caller_with_a_default_sees_it(self, client, default_address_grant):
response = client.get("/tenants/me", headers=office_headers())
assert response.status_code == 200
assert response.json()["default_address"] == default_address_grant
def test_office_caller_without_a_default_sees_null(self, client):
response = client.get("/tenants/me", headers=office_headers())
assert response.status_code == 200
assert response.json()["default_address"] is None
def test_user_caller_sees_null(self, client):
"""A user-kind caller carries no consumer, so there is no grant row
to look a default up for — null regardless of any office grant's
own default."""
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()["default_address"] is None
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"