Files

517 lines
18 KiB
Python

"""P2 HTTP route tests for the MAF roundtable API.
Covers the full HTTP status code matrix:
- **401 Unauthorized**: missing / invalid auth
- **403 Forbidden**: admin trying to write another user's resource
- **404 Not Found**: non-existent session / run / action / approval
- **409 Conflict**: concurrent run launch, already-decided approval
- **422 Unprocessable Entity**: invalid enum values (mode, write_policy, role)
- **503 Service Unavailable**: feature disabled, mock events disabled
Also verifies that identity comes from ``request.state.user`` (stamped by auth
middleware), not from body parameters —a client cannot forge ``user_id`` in the
request body to impersonate another user.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastapi import FastAPI, Request
from httpx import ASGITransport, AsyncClient
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.maf_roundtable import router as maf_router
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
class FakeUser:
"""Minimal user object for testing (mimics the real User model's shape)."""
def __init__(self, user_id: str, is_admin: bool = False):
self.id = user_id
self.system_role = "admin" if is_admin else "user"
@pytest_asyncio.fixture
async def app_and_client(tmp_path):
"""Build a minimal FastAPI app with the MAF router + a fake auth middleware."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_http.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
cfg = MafRoundtableConfig(enabled=True, admin_diagnostic_access=True)
svc = make_maf_service(store, enabled=True, admin_diagnostic_access=True, config=cfg)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.state.maf_auto_dispatch = False
app.include_router(maf_router)
# Fake auth middleware: reads X-Test-User / X-Test-Role headers and stamps
# request.state.user. No header →401 (unauthenticated).
@app.middleware("http")
async def fake_auth(request: Request, call_next):
user_id = request.headers.get("X-Test-User")
if user_id is None:
from starlette.responses import JSONResponse
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
is_admin = request.headers.get("X-Test-Role") == "admin"
request.state.user = FakeUser(user_id, is_admin)
return await call_next(request)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield app, client, store
await engine.dispose()
# ── 401 Unauthorized ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_401_missing_auth(app_and_client):
_, client, _ = app_and_client
resp = await client.get("/api/maf-roundtable/sessions")
assert resp.status_code == 401
# ── 403 Forbidden (admin write cross-user) ────────────────────────────────
@pytest.mark.asyncio
async def test_403_admin_cannot_update_other_user_session(app_and_client):
_, client, _ = app_and_client
# alice creates a session
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
session_id = resp.json()["id"]
# admin tries to update it →403
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"title": "hijack"},
headers={"X-Test-User": "admin-1", "X-Test-Role": "admin"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_403_admin_cannot_delete_other_user_session(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.delete(
f"/api/maf-roundtable/sessions/{session_id}",
headers={"X-Test-User": "admin-1", "X-Test-Role": "admin"},
)
assert resp.status_code == 403
# ── 404 Not Found ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_404_nonexistent_session(app_and_client):
_, client, _ = app_and_client
resp = await client.get(
"/api/maf-roundtable/sessions/nonexistent-id",
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_404_other_user_session_not_leaked(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
# bob (non-admin) tries to read alice's session →404 (not 403, so existence is not leaked)
resp = await client.get(
f"/api/maf-roundtable/sessions/{session_id}",
headers={"X-Test-User": "bob"},
)
assert resp.status_code == 404
# ── 409 Conflict ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_409_second_run_launch_conflicts(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
# First run launch succeeds
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
# Second run launch →409
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_409_approval_already_decided(app_and_client):
_, client, store = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
run_id = resp.json()["id"]
# Create an approval directly via the store
appr = await store.approvals.create_approval(
{"session_id": session_id, "run_id": run_id, "summary": "x", "status": "pending"}
)
# First decision succeeds
resp = await client.post(
f"/api/maf-roundtable/approvals/{appr['id']}/decision",
json={"decision": "approved"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
# Second decision →409
resp = await client.post(
f"/api/maf-roundtable/approvals/{appr['id']}/decision",
json={"decision": "rejected"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 409
# ── 422 Unprocessable Entity (invalid enums) ──────────────────────────────
@pytest.mark.asyncio
async def test_422_invalid_mode(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "mode": "invalid_mode"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_422_invalid_write_policy(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "write_policy": "invalid_policy"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_422_invalid_participant_role(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "participants": [{"agent_id": "a1", "role": "invalid_role"}]},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
# ── 503 Service Unavailable ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_503_feature_disabled(tmp_path):
"""When the feature flag is off, write paths return 503."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_disabled.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
cfg = MafRoundtableConfig(enabled=False)
svc = make_maf_service(store, enabled=False, admin_diagnostic_access=True, config=cfg)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.state.maf_auto_dispatch = False
app.include_router(maf_router)
@app.middleware("http")
async def fake_auth(request: Request, call_next):
request.state.user = FakeUser("alice", False)
return await call_next(request)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post("/api/maf-roundtable/sessions", json={"task_text": "t"})
assert resp.status_code == 503
await engine.dispose()
@pytest.mark.asyncio
async def test_404_post_events_endpoint_removed(app_and_client):
"""POST /runs/{id}/events is deliberately absent —no public mock write path."""
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
run_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/runs/{run_id}/events",
json={"event_type": "manager.decision"},
headers={"X-Test-User": "alice"},
)
# FastAPI returns 405 when GET exists on the same path but POST is not
# registered; either 404 or 405 means no public write surface.
assert resp.status_code in (404, 405)
@pytest.mark.asyncio
async def test_internal_append_event_still_writes(app_and_client):
"""service.append_event remains available for the future MAF executor."""
app, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
run_id = resp.json()["id"]
svc = app.state.maf_roundtable_service
event = await svc.append_event(run_id=run_id, user_id="alice", event_type="run.started")
assert event["seq"] == 1
assert event["event_type"] == "run.started"
# Public GET still works for the written event.
resp = await client.get(
f"/api/maf-roundtable/runs/{run_id}/events",
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
assert len(resp.json()["events"]) == 1
# ── PUT explicit-null rejection ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_422_put_mode_null(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"mode": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_422_put_title_null(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "title": "keep"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"title": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_422_put_max_rounds_null(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"max_rounds": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_200_put_task_payload_null_clears(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "task_payload": {"k": 1}},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
assert resp.json()["task_payload"] == {"k": 1}
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"task_payload": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
assert resp.json()["task_payload"] is None
@pytest.mark.asyncio
async def test_200_put_model_name_null_clears(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "model_name": "glm-5"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
assert resp.json()["model_name"] == "glm-5"
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"model_name": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
assert resp.json()["model_name"] is None
# ── Identity forgery prevention ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_identity_from_request_state_not_body(app_and_client):
"""A client cannot forge user_id in the request body to impersonate another user.
The service reads user_id from request.state.user (stamped by auth middleware),
not from the request body. Even if a malicious client includes a user_id field
in the JSON body, it is ignored.
"""
_, client, _ = app_and_client
# alice creates a session
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
session_id = resp.json()["id"]
assert resp.json()["user_id"] == "alice"
# bob tries to create a session with a forged user_id in the body
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "user_id": "alice"}, # forged field
headers={"X-Test-User": "bob"},
)
assert resp.status_code == 200
# The session is owned by bob (from request.state.user), not alice (from body)
assert resp.json()["user_id"] == "bob"
# bob cannot read alice's session
resp = await client.get(
f"/api/maf-roundtable/sessions/{session_id}",
headers={"X-Test-User": "bob"},
)
assert resp.status_code == 404
# ── Phase 3 resume / recover ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_resume_and_recover_conflict_when_not_eligible(app_and_client):
"""Resume requires waiting_approval; recover requires running|waiting_approval."""
app, client, _ = app_and_client
app.state.maf_auto_dispatch = False
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
session_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
run_id = resp.json()["id"]
# Pending runs (auto-dispatch off) are not eligible for resume/recover.
assert resp.json()["status"] == "pending"
resp = await client.post(
f"/api/maf-roundtable/runs/{run_id}/resume",
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 409
resp = await client.post(
f"/api/maf-roundtable/runs/{run_id}/recover",
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 409