Files
chatbot_v3/tests/support/factories.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

75 lines
2.1 KiB
Python

"""Object builders for test fixtures (ADR-0016).
Insert rows via a caller-supplied `AsyncSession` without committing — callers
decide their own transaction boundary (the `db_session` fixture rolls back
after each test).
"""
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.auth.keys import generate_api_key, hash_secret
from src.infrastructure.postgres.models.api_key import ApiKey
from src.infrastructure.postgres.models.tenant import Tenant
from src.infrastructure.postgres.models.tenant_domain import TenantDomain
async def create_tenant(
session: AsyncSession, *, slug: str | None = None, status: str = "active"
) -> Tenant:
slug = slug or f"tenant-{uuid.uuid4().hex[:8]}"
tenant = Tenant(id=uuid.uuid4(), slug=slug, name=slug, status=status)
session.add(tenant)
await session.flush()
return tenant
async def create_api_key(
session: AsyncSession,
*,
tenant: Tenant,
scopes: list[str] | None = None,
status: str = "active",
) -> tuple[ApiKey, str]:
"""Returns `(api_key, full_key)`. `full_key` is the bearer token to send;
only its hash is persisted.
"""
key_prefix, secret, full_key = generate_api_key()
api_key = ApiKey(
id=uuid.uuid4(),
tenant_id=tenant.id,
name="test-key",
key_prefix=key_prefix,
key_hash=hash_secret(secret),
scopes=scopes or ["files:write"],
status=status,
)
session.add(api_key)
await session.flush()
return api_key, full_key
async def create_tenant_domain(
session: AsyncSession,
*,
tenant: Tenant,
domain: str = "general",
status: str = "active",
) -> TenantDomain:
"""Register a domain so an upload to it passes the allowlist check.
Uploads reject an unregistered domain (ADR-0009), so any test that uploads
needs one of these.
"""
tenant_domain = TenantDomain(
id=uuid.uuid4(),
tenant_id=tenant.id,
domain=domain,
display_name=domain,
status=status,
)
session.add(tenant_domain)
await session.flush()
return tenant_domain