Files
konstruct/tests/integration/test_templates.py
Adolfo Delorenzo f9ce3d650f feat(05-01): template list/detail/deploy API + RBAC + integration tests
- Create shared/api/templates.py with templates_router
- GET /api/portal/templates: list active templates (any authenticated user)
- GET /api/portal/templates/{id}: get template detail (any authenticated user)
- POST /api/portal/templates/{id}/deploy: create Agent snapshot (tenant_admin only)
- customer_operator returns 403 on deploy (RBAC enforced)
- Export templates_router from shared/api/__init__.py
- Mount templates_router in gateway/main.py (Phase 5 section)
- 11 integration tests pass (list, detail, deploy, RBAC, 404, snapshot independence)
2026-03-24 20:32:30 -06:00

450 lines
14 KiB
Python

"""
Integration tests for agent template API endpoints.
Covers:
- GET /api/portal/templates returns 7+ seeded templates
- GET /api/portal/templates/{id} returns correct template detail
- POST /api/portal/templates/{id}/deploy creates agent snapshot with is_active=True
- POST deploy as customer_operator returns 403
- POST deploy with invalid UUID returns 404
- Deployed agent fields match template (snapshot verification)
Test infrastructure follows the same pattern as test_portal_rbac.py:
- Session override via app.dependency_overrides
- X-Portal-User-Id / X-Portal-User-Role / X-Portal-Tenant-Id header injection
- db_session fixture from tests/conftest.py (Alembic migrations applied)
"""
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.api.templates import templates_router
from shared.db import get_session
from shared.models.auth import PortalUser, UserTenantRole
from shared.models.tenant import Tenant
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def make_app(session: AsyncSession) -> FastAPI:
"""Build a minimal FastAPI test app with portal + templates routers."""
app = FastAPI()
app.include_router(portal_router)
app.include_router(templates_router)
async def override_get_session(): # type: ignore[return]
yield session
app.dependency_overrides[get_session] = override_get_session
return app
# ---------------------------------------------------------------------------
# RBAC header helpers
# ---------------------------------------------------------------------------
def platform_admin_headers(user_id: uuid.UUID) -> dict[str, str]:
return {
"X-Portal-User-Id": str(user_id),
"X-Portal-User-Role": "platform_admin",
}
def customer_admin_headers(user_id: uuid.UUID, tenant_id: uuid.UUID) -> dict[str, str]:
return {
"X-Portal-User-Id": str(user_id),
"X-Portal-User-Role": "customer_admin",
"X-Portal-Tenant-Id": str(tenant_id),
}
def customer_operator_headers(user_id: uuid.UUID, tenant_id: uuid.UUID) -> dict[str, str]:
return {
"X-Portal-User-Id": str(user_id),
"X-Portal-User-Role": "customer_operator",
"X-Portal-Tenant-Id": str(tenant_id),
}
# ---------------------------------------------------------------------------
# DB setup helpers
# ---------------------------------------------------------------------------
async def _create_tenant(session: AsyncSession, name: str | None = None) -> Tenant:
suffix = uuid.uuid4().hex[:8]
tenant = Tenant(
id=uuid.uuid4(),
name=name or f"Template Test Tenant {suffix}",
slug=f"tmpl-test-{suffix}",
settings={},
)
session.add(tenant)
await session.flush()
return tenant
async def _create_user(session: AsyncSession, role: str = "customer_admin") -> PortalUser:
import bcrypt
suffix = uuid.uuid4().hex[:8]
hashed = bcrypt.hashpw(b"testpassword123", bcrypt.gensalt()).decode()
user = PortalUser(
id=uuid.uuid4(),
email=f"tmpl-test-{suffix}@example.com",
hashed_password=hashed,
name=f"Template Test User {suffix}",
role=role,
)
session.add(user)
await session.flush()
return user
async def _grant_membership(
session: AsyncSession,
user: PortalUser,
tenant: Tenant,
role: str,
) -> UserTenantRole:
membership = UserTenantRole(
id=uuid.uuid4(),
user_id=user.id,
tenant_id=tenant.id,
role=role,
)
session.add(membership)
await session.flush()
return membership
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def template_setup(db_session: AsyncSession) -> dict[str, Any]:
"""
Set up tenant, users, and memberships for template tests.
Returns:
- tenant: the primary test tenant
- platform_admin: platform_admin user
- customer_admin: customer_admin user with membership in tenant
- operator: customer_operator user with membership in tenant
"""
tenant = await _create_tenant(db_session)
platform_admin = await _create_user(db_session, role="platform_admin")
customer_admin = await _create_user(db_session, role="customer_admin")
operator = await _create_user(db_session, role="customer_operator")
await _grant_membership(db_session, customer_admin, tenant, "customer_admin")
await _grant_membership(db_session, operator, tenant, "customer_operator")
await db_session.commit()
return {
"tenant": tenant,
"platform_admin": platform_admin,
"customer_admin": customer_admin,
"operator": operator,
}
@pytest_asyncio.fixture
async def templates_client(db_session: AsyncSession) -> AsyncClient:
"""HTTP client with templates router mounted."""
app = make_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
# ---------------------------------------------------------------------------
# Tests: GET /api/portal/templates
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_templates(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""GET /api/portal/templates returns 200 with 7+ active seeded templates."""
admin = template_setup["platform_admin"]
headers = platform_admin_headers(admin.id)
response = await templates_client.get("/api/portal/templates", headers=headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 7, f"Expected at least 7 seeded templates, got {len(data)}"
@pytest.mark.asyncio
async def test_list_templates_returns_expected_fields(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""Template list items include all required response fields."""
admin = template_setup["platform_admin"]
headers = platform_admin_headers(admin.id)
response = await templates_client.get("/api/portal/templates", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) > 0
# Check first template has all required fields
first = templates[0]
for field in ("id", "name", "role", "description", "category", "persona",
"system_prompt", "model_preference", "tool_assignments",
"escalation_rules", "is_active", "sort_order", "created_at"):
assert field in first, f"Missing field: {field}"
@pytest.mark.asyncio
async def test_list_templates_customer_admin_can_browse(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""customer_admin can browse templates (any authenticated user can)."""
admin = template_setup["customer_admin"]
tenant = template_setup["tenant"]
headers = customer_admin_headers(admin.id, tenant.id)
response = await templates_client.get("/api/portal/templates", headers=headers)
assert response.status_code == 200
assert len(response.json()) >= 7
@pytest.mark.asyncio
async def test_list_templates_operator_can_browse(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""customer_operator can also browse templates."""
operator = template_setup["operator"]
tenant = template_setup["tenant"]
headers = customer_operator_headers(operator.id, tenant.id)
response = await templates_client.get("/api/portal/templates", headers=headers)
assert response.status_code == 200
assert len(response.json()) >= 7
# ---------------------------------------------------------------------------
# Tests: GET /api/portal/templates/{id}
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_template_detail(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""GET single template returns correct fields including AI transparency clause."""
admin = template_setup["platform_admin"]
headers = platform_admin_headers(admin.id)
# First get the list to find a real template ID
list_resp = await templates_client.get("/api/portal/templates", headers=headers)
assert list_resp.status_code == 200
templates = list_resp.json()
assert len(templates) > 0
template_id = templates[0]["id"]
response = await templates_client.get(f"/api/portal/templates/{template_id}", headers=headers)
assert response.status_code == 200
data = response.json()
assert data["id"] == template_id
assert data["name"] == templates[0]["name"]
assert data["role"] == templates[0]["role"]
assert data["is_active"] is True
# System prompt should contain AI transparency clause
assert "When directly asked if you are an AI" in data["system_prompt"]
@pytest.mark.asyncio
async def test_get_template_not_found(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""GET with non-existent UUID returns 404."""
admin = template_setup["platform_admin"]
headers = platform_admin_headers(admin.id)
nonexistent_id = str(uuid.uuid4())
response = await templates_client.get(f"/api/portal/templates/{nonexistent_id}", headers=headers)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Tests: POST /api/portal/templates/{id}/deploy
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_deploy_template(
templates_client: AsyncClient,
template_setup: dict[str, Any],
db_session: AsyncSession,
) -> None:
"""POST deploy creates an independent Agent snapshot with is_active=True."""
admin = template_setup["customer_admin"]
tenant = template_setup["tenant"]
headers = customer_admin_headers(admin.id, tenant.id)
# Get a template to deploy
list_resp = await templates_client.get("/api/portal/templates", headers=headers)
assert list_resp.status_code == 200
templates = list_resp.json()
template = templates[0]
template_id = template["id"]
response = await templates_client.post(
f"/api/portal/templates/{template_id}/deploy",
json={"tenant_id": str(tenant.id)},
headers=headers,
)
assert response.status_code == 201
data = response.json()
agent = data["agent"]
# Agent snapshot should be active
assert agent["is_active"] is True
# Agent fields should match template
assert agent["name"] == template["name"]
assert agent["role"] == template["role"]
assert agent["persona"] == template["persona"]
assert agent["system_prompt"] == template["system_prompt"]
assert agent["model_preference"] == template["model_preference"]
assert agent["tool_assignments"] == template["tool_assignments"]
# Agent should be scoped to the tenant
assert agent["tenant_id"] == str(tenant.id)
# Agent should have an id
assert agent["id"] is not None
@pytest.mark.asyncio
async def test_deploy_template_platform_admin(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""platform_admin can deploy templates to any tenant."""
admin = template_setup["platform_admin"]
tenant = template_setup["tenant"]
headers = platform_admin_headers(admin.id)
list_resp = await templates_client.get("/api/portal/templates", headers=headers)
template = list_resp.json()[0]
template_id = template["id"]
response = await templates_client.post(
f"/api/portal/templates/{template_id}/deploy",
json={"tenant_id": str(tenant.id)},
headers=headers,
)
assert response.status_code == 201
assert response.json()["agent"]["is_active"] is True
@pytest.mark.asyncio
async def test_deploy_template_rbac_operator_forbidden(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""POST deploy as customer_operator returns 403."""
operator = template_setup["operator"]
tenant = template_setup["tenant"]
headers = customer_operator_headers(operator.id, tenant.id)
# Get a template ID
admin = template_setup["platform_admin"]
list_resp = await templates_client.get(
"/api/portal/templates",
headers=platform_admin_headers(admin.id),
)
template_id = list_resp.json()[0]["id"]
response = await templates_client.post(
f"/api/portal/templates/{template_id}/deploy",
json={"tenant_id": str(tenant.id)},
headers=headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_deploy_template_not_found(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""POST deploy with invalid/nonexistent template UUID returns 404."""
admin = template_setup["customer_admin"]
tenant = template_setup["tenant"]
headers = customer_admin_headers(admin.id, tenant.id)
nonexistent_id = str(uuid.uuid4())
response = await templates_client.post(
f"/api/portal/templates/{nonexistent_id}/deploy",
json={"tenant_id": str(tenant.id)},
headers=headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_deploy_creates_independent_snapshot(
templates_client: AsyncClient,
template_setup: dict[str, Any],
) -> None:
"""Deploying the same template twice creates two independent agents."""
admin = template_setup["customer_admin"]
tenant = template_setup["tenant"]
headers = customer_admin_headers(admin.id, tenant.id)
list_resp = await templates_client.get("/api/portal/templates", headers=headers)
template_id = list_resp.json()[0]["id"]
resp1 = await templates_client.post(
f"/api/portal/templates/{template_id}/deploy",
json={"tenant_id": str(tenant.id)},
headers=headers,
)
resp2 = await templates_client.post(
f"/api/portal/templates/{template_id}/deploy",
json={"tenant_id": str(tenant.id)},
headers=headers,
)
assert resp1.status_code == 201
assert resp2.status_code == 201
# Both agents should have distinct IDs
agent1_id = resp1.json()["agent"]["id"]
agent2_id = resp2.json()["agent"]["id"]
assert agent1_id != agent2_id