"""`/v1/domains` over HTTP, against real Postgres (ADR-0008, ADR-0009). The property worth testing at this layer is the scope boundary: an upload key must not be able to create domains, or the allowlist stops preventing anything. """ from collections.abc import AsyncIterator import pytest import pytest_asyncio from asgi_lifespan import LifespanManager from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.bootstrap.dependencies import get_sessionmaker from src.config import Settings from src.main import create_app from tests.support.factories import create_api_key, create_tenant, create_tenant_domain pytestmark = [ pytest.mark.integration, pytest.mark.postgres, pytest.mark.asyncio(loop_scope="session"), ] @pytest_asyncio.fixture(loop_scope="session") async def api_client( settings: Settings, db_sessionmaker: async_sessionmaker[AsyncSession] ) -> AsyncIterator[AsyncClient]: """The real app, with only the database swapped for the test's session factory -- so routing, auth, scopes, and the error envelope are exercised. """ app = create_app(settings) app.dependency_overrides[get_sessionmaker] = lambda: db_sessionmaker async with ( LifespanManager(app) as manager, AsyncClient(transport=ASGITransport(app=manager.app), base_url="http://test") as client, ): yield client def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} async def test_create_domain_returns_201_and_lists_it( api_client: AsyncClient, db_session: AsyncSession ) -> None: tenant = await create_tenant(db_session) _, token = await create_api_key( db_session, tenant=tenant, scopes=["domains:read", "domains:write"] ) await db_session.commit() created = await api_client.post( "/v1/domains", json={"domain": "fire", "display_name": "Fire insurance"}, headers=_auth(token), ) listed = await api_client.get("/v1/domains", headers=_auth(token)) assert created.status_code == 201 assert created.json()["domain"] == "fire" assert [item["domain"] for item in listed.json()["domains"]] == ["fire"] async def test_create_domain_requires_the_domains_write_scope( api_client: AsyncClient, db_session: AsyncSession ) -> None: """An upload key creating domains would defeat the allowlist entirely.""" tenant = await create_tenant(db_session) _, token = await create_api_key(db_session, tenant=tenant, scopes=["files:write"]) await db_session.commit() response = await api_client.post( "/v1/domains", json={"domain": "fire", "display_name": "Fire"}, headers=_auth(token), ) assert response.status_code == 403 assert response.json()["error"]["code"] == "missing_scope" async def test_list_domains_never_shows_another_tenants_domains( api_client: AsyncClient, db_session: AsyncSession ) -> None: owner = await create_tenant(db_session) other = await create_tenant(db_session) await create_tenant_domain(db_session, tenant=owner, domain="fire") _, other_token = await create_api_key(db_session, tenant=other, scopes=["domains:read"]) await db_session.commit() response = await api_client.get("/v1/domains", headers=_auth(other_token)) assert response.status_code == 200 assert response.json()["domains"] == [] async def test_create_domain_rejects_a_duplicate_with_409( api_client: AsyncClient, db_session: AsyncSession ) -> None: tenant = await create_tenant(db_session) await create_tenant_domain(db_session, tenant=tenant, domain="fire") _, token = await create_api_key(db_session, tenant=tenant, scopes=["domains:write"]) await db_session.commit() response = await api_client.post( "/v1/domains", json={"domain": "fire", "display_name": "Fire"}, headers=_auth(token), ) assert response.status_code == 409 assert response.json()["error"]["code"] == "conflict" async def test_create_domain_rejects_a_malformed_key( api_client: AsyncClient, db_session: AsyncSession ) -> None: tenant = await create_tenant(db_session) _, token = await create_api_key(db_session, tenant=tenant, scopes=["domains:write"]) await db_session.commit() response = await api_client.post( "/v1/domains", json={"domain": "Fire Insurance!", "display_name": "Fire"}, headers=_auth(token), ) assert response.status_code == 422 async def test_patch_domain_cannot_rename_the_key( api_client: AsyncClient, db_session: AsyncSession ) -> None: """`domain` is not part of the update schema -- it is denormalized into every point payload, so renaming it is a migration, not an edit. """ tenant = await create_tenant(db_session) await create_tenant_domain(db_session, tenant=tenant, domain="fire") _, token = await create_api_key( db_session, tenant=tenant, scopes=["domains:read", "domains:write"] ) await db_session.commit() response = await api_client.patch( "/v1/domains/fire", json={"display_name": "Fire & perils", "domain": "renamed"}, headers=_auth(token), ) assert response.status_code == 200 assert response.json()["domain"] == "fire" assert response.json()["display_name"] == "Fire & perils" async def test_delete_domain_disables_it_without_removing_it( api_client: AsyncClient, db_session: AsyncSession ) -> None: tenant = await create_tenant(db_session) await create_tenant_domain(db_session, tenant=tenant, domain="fire") _, token = await create_api_key( db_session, tenant=tenant, scopes=["domains:read", "domains:write"] ) await db_session.commit() deleted = await api_client.delete("/v1/domains/fire", headers=_auth(token)) default_list = await api_client.get("/v1/domains", headers=_auth(token)) full_list = await api_client.get( "/v1/domains", params={"include_disabled": True}, headers=_auth(token) ) assert deleted.status_code == 200 assert deleted.json()["status"] == "disabled" assert default_list.json()["domains"] == [] assert [item["domain"] for item in full_list.json()["domains"]] == ["fire"] async def test_patch_unknown_domain_returns_400( api_client: AsyncClient, db_session: AsyncSession ) -> None: tenant = await create_tenant(db_session) _, token = await create_api_key(db_session, tenant=tenant, scopes=["domains:write"]) await db_session.commit() response = await api_client.patch( "/v1/domains/absent", json={"display_name": "x"}, headers=_auth(token) ) assert response.status_code == 400 assert response.json()["error"]["code"] == "unknown_domain"