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