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
This commit is contained in:
2026-03-23 10:05:07 -06:00
parent ee2f88e13b
commit 7b348b97e9
6 changed files with 1149 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ dependencies = [
"celery[redis]>=5.4.0", "celery[redis]>=5.4.0",
"httpx>=0.28.0", "httpx>=0.28.0",
"slowapi>=0.1.9", "slowapi>=0.1.9",
"bcrypt>=4.0.0",
] ]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]

View File

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

View File

@@ -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)

View File

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

View File

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

2
uv.lock generated
View File

@@ -1205,6 +1205,7 @@ name = "konstruct-orchestrator"
version = "0.1.0" version = "0.1.0"
source = { editable = "packages/orchestrator" } source = { editable = "packages/orchestrator" }
dependencies = [ dependencies = [
{ name = "celery", extra = ["redis"] },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "httpx" }, { name = "httpx" },
{ name = "konstruct-shared" }, { name = "konstruct-shared" },
@@ -1212,6 +1213,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "celery", extras = ["redis"], specifier = ">=5.4.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", specifier = ">=0.28.0" },
{ name = "konstruct-shared", editable = "packages/shared" }, { name = "konstruct-shared", editable = "packages/shared" },