Files
chatbot_v3/tests/integration/postgres/test_domains_service.py
Ali Zarinkolah e9e83b3a26 feat(tenant): add tenant_domains allowlist and /v1/domains management API
Why:
- Domain values are denormalized into every Qdrant point payload. Without
  validation, an unregistered or typo'd domain (e.g. "fier" for "fire")
  silently creates a new partition that retrieval never queries — the file
  ends up invisible rather than rejected. Tenants also need independently
  sized domain sets (one may run 14 insurance lines, another 6), which rules
  out an enum.

Changes:
- tenant_domains table (migration 41335d162de8) + repository, unique on
  (tenant_id, domain).
- src/application/domains/: ensure_domain_allowed() is the strict-allowlist
  check now run inside upload_source_file()'s first transaction, before any
  MinIO object, job row, or Qdrant point is written.
- /v1/domains (list/create/patch/disable/enable) gated on its own
  domains:read/domains:write scopes, deliberately separate from files:write
  so an upload key cannot create partitions. domain itself is immutable
  (denormalized into every point payload); only display_name is editable.
  Disable blocks new uploads without touching already-indexed points.

Impact:
- BREAKING: POST /v1/files now rejects any domain without an active
  tenant_domains row (400, unknown_domain). A domain must be created via
  POST /v1/domains before the first upload to it.
2026-08-20 18:20:24 +03:30

185 lines
6.9 KiB
Python

"""Tenant-domain management and the upload-time allowlist (ADR-0009)."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.domains import (
DomainAlreadyExistsError,
UnknownDomainError,
create_domain,
ensure_domain_allowed,
list_domains,
set_domain_status,
update_domain,
)
from tests.support.factories import create_tenant, create_tenant_domain
pytestmark = [
pytest.mark.integration,
pytest.mark.postgres,
pytest.mark.asyncio(loop_scope="session"),
]
async def test_ensure_domain_allowed_passes_for_a_registered_active_domain(
db_session: AsyncSession,
) -> None:
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
await ensure_domain_allowed(db_session, tenant_id=tenant.id, domain="fire")
async def test_ensure_domain_allowed_rejects_an_unregistered_domain(
db_session: AsyncSession,
) -> None:
"""The typo case: `fier` must not silently become a new Qdrant partition."""
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
with pytest.raises(UnknownDomainError, match="fier"):
await ensure_domain_allowed(db_session, tenant_id=tenant.id, domain="fier")
async def test_ensure_domain_allowed_rejects_a_disabled_domain(
db_session: AsyncSession,
) -> None:
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire", status="disabled")
with pytest.raises(UnknownDomainError, match="disabled"):
await ensure_domain_allowed(db_session, tenant_id=tenant.id, domain="fire")
async def test_ensure_domain_allowed_rejects_another_tenants_domain(
db_session: AsyncSession,
) -> None:
"""Domain lists are per-tenant; one tenant's `fire` is not another's."""
owner = await create_tenant(db_session)
other = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=owner, domain="fire")
with pytest.raises(UnknownDomainError):
await ensure_domain_allowed(db_session, tenant_id=other.id, domain="fire")
async def test_tenants_hold_independent_domain_sets_of_different_sizes(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
big = await create_tenant(db_session)
small = await create_tenant(db_session)
for index in range(14):
await create_tenant_domain(db_session, tenant=big, domain=f"line-{index:02d}")
for index in range(6):
await create_tenant_domain(db_session, tenant=small, domain=f"line-{index:02d}")
await db_session.commit()
assert len(await list_domains(db_sessionmaker, tenant_id=big.id)) == 14
assert len(await list_domains(db_sessionmaker, tenant_id=small.id)) == 6
async def test_create_domain_then_upload_is_allowed(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session)
await db_session.commit()
created = await create_domain(
db_sessionmaker, tenant_id=tenant.id, domain="car", display_name="Car insurance"
)
assert created.domain == "car"
assert created.status == "active"
async with db_sessionmaker() as session:
await ensure_domain_allowed(session, tenant_id=tenant.id, domain="car")
async def test_create_domain_rejects_a_duplicate_key_for_the_same_tenant(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
await db_session.commit()
with pytest.raises(DomainAlreadyExistsError):
await create_domain(
db_sessionmaker, tenant_id=tenant.id, domain="fire", display_name="Fire again"
)
async def test_create_domain_allows_the_same_key_for_different_tenants(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
first = await create_tenant(db_session)
second = await create_tenant(db_session)
await db_session.commit()
await create_domain(db_sessionmaker, tenant_id=first.id, domain="fire", display_name="Fire")
await create_domain(db_sessionmaker, tenant_id=second.id, domain="fire", display_name="Fire")
assert len(await list_domains(db_sessionmaker, tenant_id=first.id)) == 1
assert len(await list_domains(db_sessionmaker, tenant_id=second.id)) == 1
async def test_update_domain_changes_only_the_display_name(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
await db_session.commit()
updated = await update_domain(
db_sessionmaker, tenant_id=tenant.id, domain="fire", display_name="Fire & perils"
)
assert updated.display_name == "Fire & perils"
# The key is immutable: it is denormalized into every point payload.
assert updated.domain == "fire"
async def test_disabling_a_domain_blocks_new_uploads_without_deleting_it(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
await db_session.commit()
disabled = await set_domain_status(
db_sessionmaker, tenant_id=tenant.id, domain="fire", status="disabled"
)
assert disabled.status == "disabled"
async with db_sessionmaker() as session:
with pytest.raises(UnknownDomainError):
await ensure_domain_allowed(session, tenant_id=tenant.id, domain="fire")
# Still there, just hidden from the default listing.
assert await list_domains(db_sessionmaker, tenant_id=tenant.id) == []
assert len(await list_domains(db_sessionmaker, tenant_id=tenant.id, include_disabled=True)) == 1
async def test_re_enabling_a_domain_restores_uploads(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire", status="disabled")
await db_session.commit()
await set_domain_status(db_sessionmaker, tenant_id=tenant.id, domain="fire", status="active")
async with db_sessionmaker() as session:
await ensure_domain_allowed(session, tenant_id=tenant.id, domain="fire")
async def test_update_domain_rejects_another_tenants_domain(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
owner = await create_tenant(db_session)
other = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=owner, domain="fire")
await db_session.commit()
with pytest.raises(UnknownDomainError):
await update_domain(
db_sessionmaker, tenant_id=other.id, domain="fire", display_name="hijacked"
)