517 lines
18 KiB
Python
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
|