Files
roboco/tests/integration/test_orchestrator_routes.py
T
109b4d4d82 [4baffaa3] Batch A: extract route helpers (tasks/a2a/orchestrator/video/journals/role_dep/roadmap/prompter_live) (#738)
* [4baffaa3] refactor(api): relocate route-layer helpers out of batch-A files into services/schemas/deps

Move every non-@router-decorated top-level function out of
roboco/api/routes/{tasks,a2a,orchestrator,video,v1/_role_dep,roadmap,prompter_live}.py
(journals.py had none) into the module that owns its kind of concern:

- DB/side-effecting logic -> the paired roboco/services module
  (task.py, a2a.py, video_engine.py, video_post_service.py, prompter.py)
- DTO-conversion helpers -> roboco/api/schemas/{tasks,video,roadmap}.py,
  matching tasks.py's existing task_to_response pattern
- small HTTP-layer auth guards -> roboco/api/deps.py, matching its
  existing require_ceo_role/require_pm_or_above pattern

Redundant per-file _require_ceo(agent) wrappers (a2a/orchestrator/video/
roadmap) that just partial-applied an already-existing deps.py function
were inlined to direct require_ceo_role(...) calls instead of duplicated
across services. v1/_role_dep.py keeps its per-role frozenset variable
bindings since those are assignments, not function definitions, and
aren't flagged by the architectural-conventions classifier.

Route paths, schemas, and observable behavior are unchanged. Updated 5
existing test files whose imports or monkeypatch targets pointed at the
old private route-module names.

* [4baffaa3] test(conventions): pin batch-A route files already free of helper findings

* [4baffaa3] fix(api): restore fail-closed _auth_required() fallback (GHSA-4f7g-w95g-5q2c)

The batch-A route-helper relocation accidentally narrowed
_auth_required() to a truthy-only check, dropping the unset-value
fallback to settings.environment == "production". An unconfigured
production deploy would then always return False, silently accepting
unauthenticated X-Agent-Role: ceo header spoofing. Restore the
three-branch logic (explicit true/false honored, unset falls back to
the production check) and the GHSA docstring paragraph explaining it.

* [4baffaa3] fix(services): restore missing Board-Program/X-engine source-tag constants in task.py

The batch-A route-helper relocation's task.py edits had dropped ~24
module-level source-tag constants (BARFLY_SOURCE, CORONER_SOURCE,
DOGFOOD_SOURCE, LIBRARIAN_SOURCE, MEGAPHONE_SOURCE, MIRROR_SOURCE,
PERISCOPE_SOURCE, PEST_CONTROL_SOURCE, SCALES_SOURCE, SENTINEL_SOURCE,
SPACKLE_SOURCE, WAR_ROOM_SOURCE, their *_ITEM_SOURCE materialized-task
counterparts, ENV_SYNC_SOURCE, EVAL_BENCH_SOURCE, and the later X-engine
held-draft tags X_EDITORIAL_SOURCE/X_CAMPAIGN_SOURCE/X_BARFLY_SOURCE)
that ~20 downstream service/engine modules and orchestrator.py's
dispatch table import, breaking the whole FastAPI app's import chain
(deps.py -> AgentOrchestrator -> orchestrator.py -> task.py) and
failing collection on 7 test files.

Restored every missing constant in the same style/location as the
existing block, values cross-checked against board_programs.py's
PROGRAMS registry and hardcoded-string test assertions. Folded the
three new X-engine tags into X_SOURCES (x_post_service.py's
task.source not in X_SOURCES membership check gates their
approve/reject).

Also closes a pre-existing PLR0917 (too-many-positional-args) gap in
pyproject.toml's per-file-ignores for roboco/api/routes/*.py,
roboco/api/deps.py, and roboco/services/prompter.py: these files
already carry an established PLR0913 ignore with a documented
FastAPI-DI-contract / MegaTask-contract rationale that applies equally
to PLR0917, which ruff was flagging on the same pre-existing
signatures (get_current_agent_id, get_current_agent_slug,
_cloud_auth_agent_context, get_agent_context, list_tasks_summary,
_rewrite_batch_children).

* [4baffaa3] fix(api): restore verb-rejection logging and fix stale monkeypatch target in orchestrator auth tests

Two regressions surfaced by re-running the full unit test suite after
restoring task.py's import chain (previously masked because the whole
app failed to import):

1. envelope_to_response() (relocated into roboco/api/deps.py from
   v1/_role_dep.py during the batch-A helper extraction) dropped the
   "verb rejected" structlog event an error envelope must leave — a
   rejected envelope rides a 200, so without this the access log can't
   distinguish a verb an agent couldn't satisfy from one that worked
   (four Board Programs died that way on 2026-07-25 with no
   recoverable reason, per tests/unit/api/routes/v1/
   test_verb_rejection_logging.py's docstring). Restored the log call:
   verb name from the request path, error/detail/remediate from the
   envelope, agent_id/agent_role from the request headers.

2. tests/unit/api/test_orchestrator_auth.py's two cloud-auth session
   tests monkeypatched "roboco.api.routes.orchestrator.
   resolve_session_user", the pre-relocation location. The guard that
   actually calls resolve_session_user (require_orchestrator_ceo) now
   lives in roboco/api/deps.py, same as the other route auth test
   files' already-updated pattern (test_deps.py); repointed both
   patches there.

Verified via a full tests/unit/api/ + tests/unit/conventions/
test_route_helper_placement_batch_a.py run: 605 passed, 18 skipped
(Postgres-gated), 1 pre-existing failure unrelated to this diff
(test_cloud_auth.py's oauth2-form test needs a live production DB
connection, not available in this sandboxed workspace).

* [4baffaa3] docs(api-routes-schemas): reflect batch-A route-helper relocation into services/schemas/deps

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
2026-07-30 10:30:13 +00:00

337 lines
11 KiB
Python

"""Orchestrator API route coverage."""
from __future__ import annotations
from datetime import UTC, datetime
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import _ServiceHolder, set_orchestrator, validate_agent_id_param
from roboco.api.routes.orchestrator import router as orch_router
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@pytest_asyncio.fixture
async def orch_client() -> AsyncIterator[tuple[AsyncClient, MagicMock]]:
app = FastAPI()
app.include_router(orch_router, prefix="/api/orchestrator")
orchestrator = MagicMock()
set_orchestrator(orchestrator)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client, orchestrator
_ServiceHolder.orchestrator = None
app.dependency_overrides.clear()
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "ceo"}
@pytest.mark.parametrize(
"bad", ["..", ".", "../x", "a/b", "a\\b", "", "be-dev-1/../x", "x\x00y"]
)
def test_validated_agent_id_rejects_path_traversal(bad: str) -> None:
# agent_id is a request path param that flows into per-agent filesystem
# paths; a traversal vector must be rejected at the HTTP boundary with 422.
with pytest.raises(HTTPException) as exc:
validate_agent_id_param(bad)
assert exc.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
def test_validated_agent_id_accepts_real_slugs() -> None:
for slug in ("be-dev-1", "pr-reviewer-1", "main-pm", "intake", "secretary"):
assert validate_agent_id_param(slug) == slug
# ---------------------------------------------------------------------------
# /status
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_status(orch_client: tuple[AsyncClient, MagicMock]) -> None:
client, orch = orch_client
orch.get_status_summary = MagicMock(
return_value={
"total": 1,
"by_state": {"running": 1},
"waiting_count": 0,
"agents": [
{
"agent_id": "be-dev-1",
"state": "running",
"task_id": None,
"error_count": 0,
"started_at": datetime.now(UTC).isoformat(),
}
],
}
)
waiting_record = SimpleNamespace(
agent_id="be-qa-1",
task_id=None,
waiting_for="qa",
waiting_since=datetime.now(UTC),
context={},
)
orch.get_waiting_agents = MagicMock(return_value={"be-dev-1": waiting_record})
response = await client.get("/api/orchestrator/status", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_status_no_started_at(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.get_status_summary = MagicMock(
return_value={
"total": 1,
"by_state": {"running": 1},
"waiting_count": 0,
"agents": [
{
"agent_id": "be-dev-1",
"state": "running",
"task_id": None,
"error_count": 0,
"started_at": None,
}
],
}
)
orch.get_waiting_agents = MagicMock(return_value={})
response = await client.get("/api/orchestrator/status", headers=_HDR)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# /agents/{agent_id}
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_agent_status_not_found(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.get_instance = MagicMock(return_value=None)
response = await client.get("/api/orchestrator/agents/be-dev-1", headers=_HDR)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_get_agent_status_success(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
instance = SimpleNamespace(
agent_id="be-dev-1",
state=SimpleNamespace(value="running"),
current_task_id=None,
error_count=0,
started_at=datetime.now(UTC),
)
orch.get_instance = MagicMock(return_value=instance)
waiting_record = SimpleNamespace(
waiting_for="qa",
agent_id="be-dev-1",
task_id=None,
waiting_since=datetime.now(UTC),
context={},
)
orch.get_waiting_agents = MagicMock(return_value={"be-dev-1": waiting_record})
response = await client.get("/api/orchestrator/agents/be-dev-1", headers=_HDR)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_agent_status_not_waiting(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
instance = SimpleNamespace(
agent_id="be-dev-1",
state=SimpleNamespace(value="running"),
current_task_id=None,
error_count=0,
started_at=datetime.now(UTC),
)
orch.get_instance = MagicMock(return_value=instance)
orch.get_waiting_agents = MagicMock(return_value={})
response = await client.get("/api/orchestrator/agents/be-dev-1", headers=_HDR)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# /waiting
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_waiting_agents(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
record = SimpleNamespace(
agent_id="be-dev-1",
task_id="t1",
waiting_for="qa",
waiting_since=datetime.now(UTC),
context={},
)
orch.get_waiting_agents = MagicMock(return_value={"be-dev-1": record})
response = await client.get("/api/orchestrator/waiting", headers=_HDR)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# /agents/{agent_id}/spawn
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_agent_not_found(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.spawn_agent = AsyncMock(side_effect=FileNotFoundError("missing"))
response = await client.post(
"/api/orchestrator/agents/be-dev-1/spawn", headers=_HDR
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_spawn_agent_internal_error(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.spawn_agent = AsyncMock(side_effect=RuntimeError("boom"))
response = await client.post(
"/api/orchestrator/agents/be-dev-1/spawn",
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
@pytest.mark.asyncio
async def test_spawn_agent_success(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
instance = SimpleNamespace(
agent_id="be-dev-1",
state=SimpleNamespace(value="starting"),
current_task_id="t1",
error_count=0,
started_at=datetime.now(UTC),
)
orch.spawn_agent = AsyncMock(return_value=instance)
response = await client.post(
"/api/orchestrator/agents/be-dev-1/spawn",
json={
"agent_id": "be-dev-1",
"initial_prompt": "go",
"task_id": "t1",
"model": "claude-opus",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
# ---------------------------------------------------------------------------
# /agents/{agent_id}/stop
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stop_agent(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.stop_agent = AsyncMock(return_value=None)
response = await client.post("/api/orchestrator/agents/be-dev-1/stop", headers=_HDR)
assert response.status_code == HTTPStatus.NO_CONTENT
# ---------------------------------------------------------------------------
# /agents/{agent_id}/resolve-wait
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_wait_not_found(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.resolve_wait = AsyncMock(return_value=None)
response = await client.post(
"/api/orchestrator/agents/be-dev-1/resolve-wait",
json={"resolution": {"action": "fixed"}},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_resolve_wait_success(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
instance = SimpleNamespace(
agent_id="be-dev-1",
state=SimpleNamespace(value="running"),
current_task_id=None,
error_count=0,
started_at=datetime.now(UTC),
)
orch.resolve_wait = AsyncMock(return_value=instance)
response = await client.post(
"/api/orchestrator/agents/be-dev-1/resolve-wait",
json={"resolution": {"action": "ok"}},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# /agents/{agent_id}/mark-waiting
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mark_waiting(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.mark_waiting_long = AsyncMock(return_value=None)
response = await client.post(
"/api/orchestrator/agents/be-dev-1/mark-waiting?waiting_for=qa&task_id=t1",
headers=_HDR,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@pytest.mark.asyncio
async def test_orchestrator_not_initialized() -> None:
"""When orchestrator isn't set, get_orchestrator raises 503."""
app = FastAPI()
app.include_router(orch_router, prefix="/api/orchestrator")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/orchestrator/status", headers=_HDR)
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE