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:
227
tests/integration/test_portal_tenants.py
Normal file
227
tests/integration/test_portal_tenants.py
Normal 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
|
||||
Reference in New Issue
Block a user