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