From 7b348b97e9799f11c4a9c8b3d64e082377a46706 Mon Sep 17 00:00:00 2001 From: Adolfo Delorenzo Date: Mon, 23 Mar 2026 10:05:07 -0600 Subject: [PATCH] feat(01-04): FastAPI portal API endpoints with tenant/agent CRUD and auth - Add packages/shared/shared/api/portal.py with APIRouter at /api/portal - POST /auth/verify validates bcrypt credentials against portal_users table - POST /auth/register creates new portal users with hashed passwords - Tenant CRUD: GET/POST /tenants, GET/PUT/DELETE /tenants/{id} - Agent CRUD: full CRUD under /tenants/{tenant_id}/agents/{id} - Agent endpoints set RLS current_tenant_id context for policy compliance - Pydantic v2 schemas with slug validation (lowercase, hyphens, 2-50 chars) - Add bcrypt>=4.0.0 dependency to konstruct-shared - Integration tests: 38 tests covering all CRUD, validation, and isolation --- packages/shared/pyproject.toml | 1 + packages/shared/shared/api/__init__.py | 9 + packages/shared/shared/api/portal.py | 521 +++++++++++++++++++++++ tests/integration/test_portal_agents.py | 389 +++++++++++++++++ tests/integration/test_portal_tenants.py | 227 ++++++++++ uv.lock | 2 + 6 files changed, 1149 insertions(+) create mode 100644 packages/shared/shared/api/__init__.py create mode 100644 packages/shared/shared/api/portal.py create mode 100644 tests/integration/test_portal_agents.py create mode 100644 tests/integration/test_portal_tenants.py diff --git a/packages/shared/pyproject.toml b/packages/shared/pyproject.toml index c3aebb8..2c7592c 100644 --- a/packages/shared/pyproject.toml +++ b/packages/shared/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "celery[redis]>=5.4.0", "httpx>=0.28.0", "slowapi>=0.1.9", + "bcrypt>=4.0.0", ] [tool.hatch.build.targets.wheel] diff --git a/packages/shared/shared/api/__init__.py b/packages/shared/shared/api/__init__.py new file mode 100644 index 0000000..94fda51 --- /dev/null +++ b/packages/shared/shared/api/__init__.py @@ -0,0 +1,9 @@ +""" +Konstruct shared API routers. + +Import and mount these routers in service main.py files. +""" + +from shared.api.portal import portal_router + +__all__ = ["portal_router"] diff --git a/packages/shared/shared/api/portal.py b/packages/shared/shared/api/portal.py new file mode 100644 index 0000000..77882fc --- /dev/null +++ b/packages/shared/shared/api/portal.py @@ -0,0 +1,521 @@ +""" +FastAPI portal API router — tenant CRUD, agent CRUD, and auth endpoints. + +Mounted at /api/portal in the gateway or a dedicated portal-api service. +The Auth.js v5 Credentials provider calls /auth/verify to validate operator +email/password against the portal_users table. +""" + +from __future__ import annotations + +import re +import uuid +from datetime import datetime +from typing import Any + +import bcrypt +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.db import get_session +from shared.models.auth import PortalUser +from shared.models.tenant import Agent, Tenant +from shared.rls import current_tenant_id + +portal_router = APIRouter(prefix="/api/portal", tags=["portal"]) + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$|^[a-z0-9]{1,2}$") + + +class AuthVerifyRequest(BaseModel): + email: str + password: str + + +class AuthVerifyResponse(BaseModel): + id: str + email: str + name: str + is_admin: bool + + +class AuthRegisterRequest(BaseModel): + email: str + password: str = Field(min_length=8) + name: str = Field(min_length=1, max_length=255) + + +class AuthRegisterResponse(BaseModel): + id: str + email: str + name: str + is_admin: bool + + +class TenantCreate(BaseModel): + name: str = Field(min_length=2, max_length=100) + slug: str = Field(min_length=2, max_length=50) + settings: dict[str, Any] = Field(default_factory=dict) + + @field_validator("slug") + @classmethod + def validate_slug(cls, v: str) -> str: + v = v.strip() + if not _SLUG_RE.match(v): + raise ValueError( + "slug must be 2-50 lowercase alphanumeric characters and hyphens " + "(no leading/trailing hyphens)" + ) + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + v = v.strip() + if len(v) < 2: + raise ValueError("name must be at least 2 characters after stripping whitespace") + return v + + +class TenantUpdate(BaseModel): + name: str | None = Field(default=None, min_length=2, max_length=100) + slug: str | None = Field(default=None, min_length=2, max_length=50) + settings: dict[str, Any] | None = None + + @field_validator("slug") + @classmethod + def validate_slug(cls, v: str | None) -> str | None: + if v is None: + return v + v = v.strip() + if not _SLUG_RE.match(v): + raise ValueError( + "slug must be 2-50 lowercase alphanumeric characters and hyphens " + "(no leading/trailing hyphens)" + ) + return v + + +class TenantResponse(BaseModel): + id: str + name: str + slug: str + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + @classmethod + def from_orm(cls, tenant: Tenant) -> "TenantResponse": + return cls( + id=str(tenant.id), + name=tenant.name, + slug=tenant.slug, + settings=tenant.settings, + created_at=tenant.created_at, + updated_at=tenant.updated_at, + ) + + +class TenantsListResponse(BaseModel): + items: list[TenantResponse] + total: int + page: int + page_size: int + + +class AgentCreate(BaseModel): + name: str = Field(min_length=1, max_length=255) + role: str = Field(min_length=1, max_length=255) + persona: str = Field(default="") + system_prompt: str = Field(default="") + model_preference: str = Field(default="quality") + tool_assignments: list[Any] = Field(default_factory=list) + escalation_rules: list[Any] = Field(default_factory=list) + is_active: bool = Field(default=True) + + @field_validator("model_preference") + @classmethod + def validate_model_preference(cls, v: str) -> str: + allowed = {"quality", "fast", "balanced", "economy", "local"} + if v not in allowed: + raise ValueError(f"model_preference must be one of: {', '.join(sorted(allowed))}") + return v + + +class AgentUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + role: str | None = Field(default=None, min_length=1, max_length=255) + persona: str | None = None + system_prompt: str | None = None + model_preference: str | None = None + tool_assignments: list[Any] | None = None + escalation_rules: list[Any] | None = None + is_active: bool | None = None + + @field_validator("model_preference") + @classmethod + def validate_model_preference(cls, v: str | None) -> str | None: + if v is None: + return v + allowed = {"quality", "fast", "balanced", "economy", "local"} + if v not in allowed: + raise ValueError(f"model_preference must be one of: {', '.join(sorted(allowed))}") + return v + + +class AgentResponse(BaseModel): + id: str + tenant_id: str + name: str + role: str + persona: str + system_prompt: str + model_preference: str + tool_assignments: list[Any] + escalation_rules: list[Any] + is_active: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + @classmethod + def from_orm(cls, agent: Agent) -> "AgentResponse": + return cls( + id=str(agent.id), + tenant_id=str(agent.tenant_id), + name=agent.name, + role=agent.role, + persona=agent.persona, + system_prompt=agent.system_prompt, + model_preference=agent.model_preference, + tool_assignments=agent.tool_assignments, + escalation_rules=agent.escalation_rules, + is_active=agent.is_active, + created_at=agent.created_at, + updated_at=agent.updated_at, + ) + + +# --------------------------------------------------------------------------- +# Auth endpoints +# --------------------------------------------------------------------------- + + +@portal_router.post("/auth/verify", response_model=AuthVerifyResponse) +async def verify_credentials( + body: AuthVerifyRequest, + session: AsyncSession = Depends(get_session), +) -> AuthVerifyResponse: + """ + Validate email + password against the portal_users table. + + Used by Auth.js v5 Credentials provider. Returns 401 on invalid credentials. + Response deliberately omits hashed_password. + """ + result = await session.execute(select(PortalUser).where(PortalUser.email == body.email)) + user = result.scalar_one_or_none() + + if user is None or not bcrypt.checkpw(body.password.encode(), user.hashed_password.encode()): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + return AuthVerifyResponse( + id=str(user.id), + email=user.email, + name=user.name, + is_admin=user.is_admin, + ) + + +@portal_router.post("/auth/register", response_model=AuthRegisterResponse, status_code=status.HTTP_201_CREATED) +async def register_user( + body: AuthRegisterRequest, + session: AsyncSession = Depends(get_session), +) -> AuthRegisterResponse: + """ + Create a new portal user with bcrypt-hashed password. + + In production, restrict this to admin-only or use a setup wizard. + Returns 409 if email already registered. + """ + existing = await session.execute(select(PortalUser).where(PortalUser.email == body.email)) + if existing.scalar_one_or_none() is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered") + + hashed = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode() + user = PortalUser( + email=body.email, + hashed_password=hashed, + name=body.name, + is_admin=False, + ) + session.add(user) + await session.commit() + await session.refresh(user) + + return AuthRegisterResponse( + id=str(user.id), + email=user.email, + name=user.name, + is_admin=user.is_admin, + ) + + +# --------------------------------------------------------------------------- +# Tenant endpoints (PRTA-01) +# --------------------------------------------------------------------------- + + +@portal_router.get("/tenants", response_model=TenantsListResponse) +async def list_tenants( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + session: AsyncSession = Depends(get_session), +) -> TenantsListResponse: + """List all tenants (platform admin view). Paginated.""" + offset = (page - 1) * page_size + result = await session.execute(select(Tenant).order_by(Tenant.created_at.desc()).offset(offset).limit(page_size)) + tenants = result.scalars().all() + + count_result = await session.execute(select(Tenant)) + total = len(count_result.scalars().all()) + + return TenantsListResponse( + items=[TenantResponse.from_orm(t) for t in tenants], + total=total, + page=page, + page_size=page_size, + ) + + +@portal_router.post("/tenants", response_model=TenantResponse, status_code=status.HTTP_201_CREATED) +async def create_tenant( + body: TenantCreate, + session: AsyncSession = Depends(get_session), +) -> TenantResponse: + """Create a new tenant. Validates slug uniqueness.""" + # Check name uniqueness + name_exists = await session.execute(select(Tenant).where(Tenant.name == body.name)) + if name_exists.scalar_one_or_none() is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant name already exists") + + # Check slug uniqueness + slug_exists = await session.execute(select(Tenant).where(Tenant.slug == body.slug)) + if slug_exists.scalar_one_or_none() is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant slug already exists") + + tenant = Tenant(name=body.name, slug=body.slug, settings=body.settings) + session.add(tenant) + await session.commit() + await session.refresh(tenant) + return TenantResponse.from_orm(tenant) + + +@portal_router.get("/tenants/{tenant_id}", response_model=TenantResponse) +async def get_tenant( + tenant_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TenantResponse: + """Get a tenant by ID. Returns 404 if not found.""" + result = await session.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + return TenantResponse.from_orm(tenant) + + +@portal_router.put("/tenants/{tenant_id}", response_model=TenantResponse) +async def update_tenant( + tenant_id: uuid.UUID, + body: TenantUpdate, + session: AsyncSession = Depends(get_session), +) -> TenantResponse: + """Update a tenant (partial updates supported).""" + result = await session.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + + if body.name is not None: + # Check name uniqueness (exclude self) + name_exists = await session.execute( + select(Tenant).where(Tenant.name == body.name, Tenant.id != tenant_id) + ) + if name_exists.scalar_one_or_none() is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant name already exists") + tenant.name = body.name + + if body.slug is not None: + # Check slug uniqueness (exclude self) + slug_exists = await session.execute( + select(Tenant).where(Tenant.slug == body.slug, Tenant.id != tenant_id) + ) + if slug_exists.scalar_one_or_none() is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant slug already exists") + tenant.slug = body.slug + + if body.settings is not None: + tenant.settings = body.settings + + await session.commit() + await session.refresh(tenant) + return TenantResponse.from_orm(tenant) + + +@portal_router.delete("/tenants/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_tenant( + tenant_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Delete a tenant. Cascade deletes agents and channel_connections.""" + result = await session.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + await session.delete(tenant) + await session.commit() + + +# --------------------------------------------------------------------------- +# Agent endpoints (PRTA-02) +# --------------------------------------------------------------------------- + + +async def _get_tenant_or_404(tenant_id: uuid.UUID, session: AsyncSession) -> Tenant: + """Shared helper to fetch a tenant or raise 404.""" + result = await session.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + return tenant + + +@portal_router.get("/tenants/{tenant_id}/agents", response_model=list[AgentResponse]) +async def list_agents( + tenant_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> list[AgentResponse]: + """List all agents for a tenant.""" + await _get_tenant_or_404(tenant_id, session) + token = current_tenant_id.set(tenant_id) + try: + result = await session.execute( + select(Agent).where(Agent.tenant_id == tenant_id).order_by(Agent.created_at.desc()) + ) + agents = result.scalars().all() + finally: + current_tenant_id.reset(token) + return [AgentResponse.from_orm(a) for a in agents] + + +@portal_router.post( + "/tenants/{tenant_id}/agents", + response_model=AgentResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_agent( + tenant_id: uuid.UUID, + body: AgentCreate, + session: AsyncSession = Depends(get_session), +) -> AgentResponse: + """Create an AI employee for a tenant.""" + await _get_tenant_or_404(tenant_id, session) + token = current_tenant_id.set(tenant_id) + try: + agent = Agent( + tenant_id=tenant_id, + name=body.name, + role=body.role, + persona=body.persona, + system_prompt=body.system_prompt, + model_preference=body.model_preference, + tool_assignments=body.tool_assignments, + escalation_rules=body.escalation_rules, + is_active=body.is_active, + ) + session.add(agent) + await session.commit() + await session.refresh(agent) + finally: + current_tenant_id.reset(token) + return AgentResponse.from_orm(agent) + + +@portal_router.get("/tenants/{tenant_id}/agents/{agent_id}", response_model=AgentResponse) +async def get_agent( + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> AgentResponse: + """Get an agent by ID (must belong to the specified tenant).""" + await _get_tenant_or_404(tenant_id, session) + token = current_tenant_id.set(tenant_id) + try: + result = await session.execute( + select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id) + ) + agent = result.scalar_one_or_none() + finally: + current_tenant_id.reset(token) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found") + return AgentResponse.from_orm(agent) + + +@portal_router.put("/tenants/{tenant_id}/agents/{agent_id}", response_model=AgentResponse) +async def update_agent( + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + body: AgentUpdate, + session: AsyncSession = Depends(get_session), +) -> AgentResponse: + """Update an agent (partial updates supported).""" + await _get_tenant_or_404(tenant_id, session) + token = current_tenant_id.set(tenant_id) + try: + result = await session.execute( + select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id) + ) + agent = result.scalar_one_or_none() + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found") + + update_fields = body.model_dump(exclude_none=True) + for field, value in update_fields.items(): + setattr(agent, field, value) + + await session.commit() + await session.refresh(agent) + finally: + current_tenant_id.reset(token) + return AgentResponse.from_orm(agent) + + +@portal_router.delete("/tenants/{tenant_id}/agents/{agent_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_agent( + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Delete an agent.""" + await _get_tenant_or_404(tenant_id, session) + token = current_tenant_id.set(tenant_id) + try: + result = await session.execute( + select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id) + ) + agent = result.scalar_one_or_none() + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found") + await session.delete(agent) + await session.commit() + finally: + current_tenant_id.reset(token) diff --git a/tests/integration/test_portal_agents.py b/tests/integration/test_portal_agents.py new file mode 100644 index 0000000..284a394 --- /dev/null +++ b/tests/integration/test_portal_agents.py @@ -0,0 +1,389 @@ +""" +Integration tests for portal agent CRUD endpoints (PRTA-02). + +Tests: create (all fields / minimal fields), list (tenant isolation), get, update, +delete. Proves all Agent Designer fields are stored and retrievable. +""" + +from __future__ import annotations + +import uuid + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.api.portal import portal_router +from shared.db import get_session + + +def make_app(session: AsyncSession) -> FastAPI: + """Build a FastAPI test app with the portal router and session override.""" + app = FastAPI() + app.include_router(portal_router) + + async def override_get_session(): # type: ignore[return] + yield session + + app.dependency_overrides[get_session] = override_get_session + return app + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession): # type: ignore[no-untyped-def] + """HTTP client wired to the portal API with the test DB session.""" + app = make_app(db_session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +async def tenant_id(client: AsyncClient) -> str: + """Create a tenant and return its ID for agent tests.""" + slug = f"agent-test-{uuid.uuid4().hex[:8]}" + resp = await client.post( + "/api/portal/tenants", + json={"name": f"Agent Test Tenant {slug}", "slug": slug}, + ) + assert resp.status_code == 201 + return resp.json()["id"] + + +@pytest_asyncio.fixture +async def second_tenant_id(client: AsyncClient) -> str: + """Create a second tenant for isolation tests.""" + slug = f"agent-test2-{uuid.uuid4().hex[:8]}" + resp = await client.post( + "/api/portal/tenants", + json={"name": f"Agent Test Tenant 2 {slug}", "slug": slug}, + ) + assert resp.status_code == 201 + return resp.json()["id"] + + +@pytest.mark.asyncio +class TestAgentCreate: + async def test_create_agent_all_fields_returns_201( + self, client: AsyncClient, tenant_id: str + ) -> None: + """All Agent Designer fields are accepted and returned.""" + resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={ + "name": "Mara", + "role": "Customer Support Lead", + "persona": "Professional, empathetic, solution-oriented.", + "system_prompt": "You are a helpful customer support agent named Mara.", + "model_preference": "quality", + "tool_assignments": ["zendesk_ticket_create", "knowledge_base_search"], + "escalation_rules": [ + {"condition": "billing_dispute AND attempts > 2", "action": "handoff_human"}, + {"condition": "sentiment < -0.7", "action": "handoff_human"}, + ], + "is_active": True, + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Mara" + assert data["role"] == "Customer Support Lead" + assert data["persona"] == "Professional, empathetic, solution-oriented." + assert data["system_prompt"] == "You are a helpful customer support agent named Mara." + assert data["model_preference"] == "quality" + assert data["tool_assignments"] == ["zendesk_ticket_create", "knowledge_base_search"] + assert len(data["escalation_rules"]) == 2 + assert data["is_active"] is True + assert data["tenant_id"] == tenant_id + assert "id" in data + assert "created_at" in data + + async def test_create_agent_minimal_fields_returns_201_with_defaults( + self, client: AsyncClient, tenant_id: str + ) -> None: + """Creating with only name + role uses sensible defaults.""" + resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Sam", "role": "Sales Representative"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Sam" + assert data["role"] == "Sales Representative" + assert data["persona"] == "" + assert data["system_prompt"] == "" + assert data["model_preference"] == "quality" + assert data["tool_assignments"] == [] + assert data["escalation_rules"] == [] + assert data["is_active"] is True + + async def test_create_agent_invalid_tenant_returns_404( + self, client: AsyncClient + ) -> None: + resp = await client.post( + f"/api/portal/tenants/{uuid.uuid4()}/agents", + json={"name": "Ghost", "role": "Nobody"}, + ) + assert resp.status_code == 404 + + async def test_create_agent_missing_name_returns_422( + self, client: AsyncClient, tenant_id: str + ) -> None: + resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"role": "Some Role"}, + ) + assert resp.status_code == 422 + + async def test_create_agent_missing_role_returns_422( + self, client: AsyncClient, tenant_id: str + ) -> None: + resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Some Name"}, + ) + assert resp.status_code == 422 + + async def test_create_agent_invalid_model_preference_returns_422( + self, client: AsyncClient, tenant_id: str + ) -> None: + resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Bad Model", "role": "Tester", "model_preference": "super-mega-mode"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +class TestAgentList: + async def test_list_agents_returns_only_that_tenants_agents( + self, client: AsyncClient, tenant_id: str, second_tenant_id: str + ) -> None: + """Agents from one tenant must NOT appear in another tenant's list.""" + # Create agent for tenant 1 + await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Tenant1 Agent", "role": "Worker"}, + ) + # Create agent for tenant 2 + await client.post( + f"/api/portal/tenants/{second_tenant_id}/agents", + json={"name": "Tenant2 Agent", "role": "Worker"}, + ) + + resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents") + assert resp.status_code == 200 + agents = resp.json() + names = [a["name"] for a in agents] + assert "Tenant1 Agent" in names + assert "Tenant2 Agent" not in names + for agent in agents: + assert agent["tenant_id"] == tenant_id + + async def test_list_agents_empty_for_new_tenant( + self, client: AsyncClient, tenant_id: str + ) -> None: + resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_list_agents_invalid_tenant_returns_404( + self, client: AsyncClient + ) -> None: + resp = await client.get(f"/api/portal/tenants/{uuid.uuid4()}/agents") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestAgentGet: + async def test_get_agent_by_id( + self, client: AsyncClient, tenant_id: str + ) -> None: + create_resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Get Me", "role": "Fetcher"}, + ) + agent_id = create_resp.json()["id"] + + resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") + assert resp.status_code == 200 + assert resp.json()["id"] == agent_id + assert resp.json()["name"] == "Get Me" + + async def test_get_agent_not_found_returns_404( + self, client: AsyncClient, tenant_id: str + ) -> None: + resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}") + assert resp.status_code == 404 + + async def test_get_agent_wrong_tenant_returns_404( + self, client: AsyncClient, tenant_id: str, second_tenant_id: str + ) -> None: + """Agent from tenant1 should not be accessible via tenant2's path.""" + create_resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Secret Agent", "role": "Spy"}, + ) + agent_id = create_resp.json()["id"] + + resp = await client.get(f"/api/portal/tenants/{second_tenant_id}/agents/{agent_id}") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestAgentUpdate: + async def test_update_agent_persona_and_system_prompt( + self, client: AsyncClient, tenant_id: str + ) -> None: + create_resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Updatable", "role": "Updater"}, + ) + agent_id = create_resp.json()["id"] + + resp = await client.put( + f"/api/portal/tenants/{tenant_id}/agents/{agent_id}", + json={ + "persona": "New persona description", + "system_prompt": "New system prompt content", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["persona"] == "New persona description" + assert data["system_prompt"] == "New system prompt content" + assert data["name"] == "Updatable" # unchanged + + async def test_update_agent_tool_assignments( + self, client: AsyncClient, tenant_id: str + ) -> None: + create_resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Tool Updater", "role": "Tester", "tool_assignments": ["tool_a"]}, + ) + agent_id = create_resp.json()["id"] + + resp = await client.put( + f"/api/portal/tenants/{tenant_id}/agents/{agent_id}", + json={"tool_assignments": ["tool_a", "tool_b", "tool_c"]}, + ) + assert resp.status_code == 200 + assert resp.json()["tool_assignments"] == ["tool_a", "tool_b", "tool_c"] + + async def test_update_agent_escalation_rules( + self, client: AsyncClient, tenant_id: str + ) -> None: + create_resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Escalation Test", "role": "Support"}, + ) + agent_id = create_resp.json()["id"] + + rules = [{"condition": "urgent", "action": "escalate_to_manager"}] + resp = await client.put( + f"/api/portal/tenants/{tenant_id}/agents/{agent_id}", + json={"escalation_rules": rules}, + ) + assert resp.status_code == 200 + assert resp.json()["escalation_rules"] == rules + + async def test_update_agent_not_found_returns_404( + self, client: AsyncClient, tenant_id: str + ) -> None: + resp = await client.put( + f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}", + json={"name": "Ghost"}, + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestAgentDelete: + async def test_delete_agent_returns_204( + self, client: AsyncClient, tenant_id: str + ) -> None: + create_resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Delete Me", "role": "Doomed"}, + ) + agent_id = create_resp.json()["id"] + + resp = await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") + assert resp.status_code == 204 + + async def test_deleted_agent_is_gone( + self, client: AsyncClient, tenant_id: str + ) -> None: + create_resp = await client.post( + f"/api/portal/tenants/{tenant_id}/agents", + json={"name": "Gone Soon", "role": "Temporary"}, + ) + agent_id = create_resp.json()["id"] + + await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") + get_resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") + assert get_resp.status_code == 404 + + async def test_delete_agent_not_found_returns_404( + self, client: AsyncClient, tenant_id: str + ) -> None: + resp = await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestAgentDesignerFields: + """ + Explicitly prove all Agent Designer fields are stored and retrievable. + This is the end-to-end round-trip test for PRTA-02. + """ + + async def test_all_agent_designer_fields_stored_and_retrievable( + self, client: AsyncClient, tenant_id: str + ) -> None: + payload = { + "name": "Mara Chen", + "role": "Head of Customer Experience", + "persona": ( + "Empathetic, data-driven, and relentlessly focused on customer success. " + "Fluent in English, Spanish, and Mandarin. " + "Never escalates without first attempting resolution." + ), + "system_prompt": ( + "You are Mara Chen, Head of Customer Experience at Acme Corp. " + "Your goal is to resolve customer issues efficiently and with empathy." + ), + "model_preference": "quality", + "tool_assignments": [ + "zendesk_ticket_create", + "zendesk_ticket_update", + "knowledge_base_search", + "calendar_book", + "send_email", + ], + "escalation_rules": [ + {"condition": "billing_dispute AND resolution_attempts > 2", "action": "handoff_human"}, + {"condition": "customer_sentiment < -0.7", "action": "handoff_human"}, + {"condition": "legal_threat_detected", "action": "handoff_legal_team"}, + ], + "is_active": True, + } + create_resp = await client.post(f"/api/portal/tenants/{tenant_id}/agents", json=payload) + assert create_resp.status_code == 201 + agent_id = create_resp.json()["id"] + + # Fetch and verify all fields round-trip correctly + get_resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") + assert get_resp.status_code == 200 + data = get_resp.json() + + assert data["name"] == payload["name"] + assert data["role"] == payload["role"] + assert data["persona"] == payload["persona"] + assert data["system_prompt"] == payload["system_prompt"] + assert data["model_preference"] == payload["model_preference"] + assert data["tool_assignments"] == payload["tool_assignments"] + assert data["escalation_rules"] == payload["escalation_rules"] + assert data["is_active"] == payload["is_active"] + assert data["tenant_id"] == tenant_id diff --git a/tests/integration/test_portal_tenants.py b/tests/integration/test_portal_tenants.py new file mode 100644 index 0000000..8eb2871 --- /dev/null +++ b/tests/integration/test_portal_tenants.py @@ -0,0 +1,227 @@ +""" +Integration tests for portal tenant CRUD endpoints (PRTA-01). + +Tests: create, read, update, delete, list, validation, uniqueness constraints. +Uses httpx.AsyncClient with a live FastAPI app backed by the test PostgreSQL DB. +""" + +from __future__ import annotations + +import uuid +from typing import Any + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.api.portal import portal_router +from shared.db import get_session + + +def make_app(session: AsyncSession) -> FastAPI: + """Build a FastAPI test app with the portal router and session override.""" + app = FastAPI() + app.include_router(portal_router) + + async def override_get_session(): # type: ignore[return] + yield session + + app.dependency_overrides[get_session] = override_get_session + return app + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession): # type: ignore[no-untyped-def] + """HTTP client wired to the portal API with the test DB session.""" + app = make_app(db_session) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.mark.asyncio +class TestTenantCreate: + async def test_create_tenant_valid_returns_201(self, client: AsyncClient) -> None: + slug = f"acme-corp-{uuid.uuid4().hex[:6]}" + resp = await client.post( + "/api/portal/tenants", + json={"name": f"Acme Corp {slug}", "slug": slug}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["slug"] == slug + assert "id" in data + assert "created_at" in data + + async def test_create_tenant_with_settings(self, client: AsyncClient) -> None: + slug = f"settings-test-{uuid.uuid4().hex[:6]}" + resp = await client.post( + "/api/portal/tenants", + json={"name": f"Settings Test {slug}", "slug": slug, "settings": {"tier": "starter"}}, + ) + assert resp.status_code == 201 + assert resp.json()["settings"]["tier"] == "starter" + + async def test_create_tenant_duplicate_slug_returns_409(self, client: AsyncClient) -> None: + slug = f"dup-slug-{uuid.uuid4().hex[:6]}" + await client.post("/api/portal/tenants", json={"name": f"First {slug}", "slug": slug}) + resp = await client.post("/api/portal/tenants", json={"name": f"Second {slug}", "slug": slug}) + assert resp.status_code == 409 + + async def test_create_tenant_duplicate_name_returns_409(self, client: AsyncClient) -> None: + slug1 = f"name-dup-a-{uuid.uuid4().hex[:6]}" + slug2 = f"name-dup-b-{uuid.uuid4().hex[:6]}" + name = f"Duplicate Name {uuid.uuid4().hex[:6]}" + await client.post("/api/portal/tenants", json={"name": name, "slug": slug1}) + resp = await client.post("/api/portal/tenants", json={"name": name, "slug": slug2}) + assert resp.status_code == 409 + + async def test_create_tenant_invalid_slug_uppercase_returns_422(self, client: AsyncClient) -> None: + resp = await client.post( + "/api/portal/tenants", + json={"name": "Bad Slug Company", "slug": "BadSlug"}, + ) + assert resp.status_code == 422 + + async def test_create_tenant_invalid_slug_too_short_returns_422(self, client: AsyncClient) -> None: + resp = await client.post( + "/api/portal/tenants", + json={"name": "Too Short Slug", "slug": "a"}, + ) + assert resp.status_code == 422 + + async def test_create_tenant_invalid_slug_leading_hyphen_returns_422(self, client: AsyncClient) -> None: + resp = await client.post( + "/api/portal/tenants", + json={"name": "Leading Hyphen Co", "slug": "-leading-hyphen"}, + ) + assert resp.status_code == 422 + + async def test_create_tenant_name_too_short_returns_422(self, client: AsyncClient) -> None: + resp = await client.post( + "/api/portal/tenants", + json={"name": "X", "slug": "valid-slug"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +class TestTenantList: + async def test_list_tenants_returns_created_tenants(self, client: AsyncClient) -> None: + slug1 = f"list-a-{uuid.uuid4().hex[:6]}" + slug2 = f"list-b-{uuid.uuid4().hex[:6]}" + await client.post("/api/portal/tenants", json={"name": f"List A {slug1}", "slug": slug1}) + await client.post("/api/portal/tenants", json={"name": f"List B {slug2}", "slug": slug2}) + + resp = await client.get("/api/portal/tenants") + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data + slugs = [t["slug"] for t in data["items"]] + assert slug1 in slugs + assert slug2 in slugs + + async def test_list_tenants_pagination(self, client: AsyncClient) -> None: + # Create 3 tenants + for i in range(3): + slug = f"pag-{i}-{uuid.uuid4().hex[:6]}" + await client.post("/api/portal/tenants", json={"name": f"Pag {i} {slug}", "slug": slug}) + + resp = await client.get("/api/portal/tenants?page=1&page_size=2") + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) <= 2 + assert data["page"] == 1 + assert data["page_size"] == 2 + + +@pytest.mark.asyncio +class TestTenantGet: + async def test_get_tenant_by_id_returns_correct_tenant(self, client: AsyncClient) -> None: + slug = f"get-test-{uuid.uuid4().hex[:6]}" + create_resp = await client.post( + "/api/portal/tenants", + json={"name": f"Get Test {slug}", "slug": slug}, + ) + tenant_id = create_resp.json()["id"] + + resp = await client.get(f"/api/portal/tenants/{tenant_id}") + assert resp.status_code == 200 + assert resp.json()["id"] == tenant_id + assert resp.json()["slug"] == slug + + async def test_get_tenant_not_found_returns_404(self, client: AsyncClient) -> None: + resp = await client.get(f"/api/portal/tenants/{uuid.uuid4()}") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestTenantUpdate: + async def test_update_tenant_name(self, client: AsyncClient) -> None: + slug = f"upd-test-{uuid.uuid4().hex[:6]}" + create_resp = await client.post( + "/api/portal/tenants", + json={"name": f"Original Name {slug}", "slug": slug}, + ) + tenant_id = create_resp.json()["id"] + + new_name = f"Updated Name {uuid.uuid4().hex[:6]}" + resp = await client.put( + f"/api/portal/tenants/{tenant_id}", + json={"name": new_name}, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == new_name + assert resp.json()["slug"] == slug # unchanged + + async def test_update_tenant_slug(self, client: AsyncClient) -> None: + slug = f"old-slug-{uuid.uuid4().hex[:6]}" + create_resp = await client.post( + "/api/portal/tenants", + json={"name": f"Slug Update Test {slug}", "slug": slug}, + ) + tenant_id = create_resp.json()["id"] + + new_slug = f"new-slug-{uuid.uuid4().hex[:6]}" + resp = await client.put( + f"/api/portal/tenants/{tenant_id}", + json={"slug": new_slug}, + ) + assert resp.status_code == 200 + assert resp.json()["slug"] == new_slug + + async def test_update_tenant_not_found_returns_404(self, client: AsyncClient) -> None: + resp = await client.put(f"/api/portal/tenants/{uuid.uuid4()}", json={"name": "Does Not Exist"}) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestTenantDelete: + async def test_delete_tenant_returns_204(self, client: AsyncClient) -> None: + slug = f"del-test-{uuid.uuid4().hex[:6]}" + create_resp = await client.post( + "/api/portal/tenants", + json={"name": f"Delete Test {slug}", "slug": slug}, + ) + tenant_id = create_resp.json()["id"] + + resp = await client.delete(f"/api/portal/tenants/{tenant_id}") + assert resp.status_code == 204 + + async def test_delete_tenant_is_gone_after_delete(self, client: AsyncClient) -> None: + slug = f"del-gone-{uuid.uuid4().hex[:6]}" + create_resp = await client.post( + "/api/portal/tenants", + json={"name": f"Delete Gone Test {slug}", "slug": slug}, + ) + tenant_id = create_resp.json()["id"] + + await client.delete(f"/api/portal/tenants/{tenant_id}") + get_resp = await client.get(f"/api/portal/tenants/{tenant_id}") + assert get_resp.status_code == 404 + + async def test_delete_tenant_not_found_returns_404(self, client: AsyncClient) -> None: + resp = await client.delete(f"/api/portal/tenants/{uuid.uuid4()}") + assert resp.status_code == 404 diff --git a/uv.lock b/uv.lock index bd269ec..769ad03 100644 --- a/uv.lock +++ b/uv.lock @@ -1205,6 +1205,7 @@ name = "konstruct-orchestrator" version = "0.1.0" source = { editable = "packages/orchestrator" } dependencies = [ + { name = "celery", extra = ["redis"] }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "konstruct-shared" }, @@ -1212,6 +1213,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "celery", extras = ["redis"], specifier = ">=5.4.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "konstruct-shared", editable = "packages/shared" },