mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
165 lines
5.7 KiB
Python
165 lines
5.7 KiB
Python
"""Phase 1b Task 5 — panel read routes gated behind panel token under cloud_auth.
|
|||
|
|
|
||
|
|
Covers the routers gated in this task: /api/agents, /api/kanban, /api/usage,
|
||
|
|
/api/system/rate-limits, and the a2a task reads /api/a2a/tasks[/{id}]. The
|
||
|
|
discovery endpoints (/.well-known/agent.json, /api/a2a/agents[/{id}/card])
|
||
|
|
stay ungated by design and are asserted to remain open.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import TYPE_CHECKING
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from httpx import ASGITransport, AsyncClient
|
||
|
|
from roboco.agents_config import issue_panel_token
|
||
|
|
from roboco.api import deps as _deps
|
||
|
|
from roboco.api.routes.a2a import router as a2a_router
|
||
|
|
from roboco.api.routes.agents import router as agents_router
|
||
|
|
from roboco.api.routes.kanban import router as kanban_router
|
||
|
|
from roboco.api.routes.system import router as system_router
|
||
|
|
from roboco.api.routes.usage import router as usage_router
|
||
|
|
from roboco.db.base import get_db
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from collections.abc import AsyncIterator
|
||
|
|
|
||
|
|
_SECRET = "test-secret-for-read-route-gates"
|
||
|
|
_HTTP_200 = 200
|
||
|
|
_HTTP_401 = 401
|
||
|
|
|
||
|
|
|
||
|
|
async def _fake_db() -> AsyncIterator[object]:
|
||
|
|
yield object()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def gated_client(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> AsyncIterator[AsyncClient]:
|
||
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
||
|
|
app = FastAPI()
|
||
|
|
app.include_router(agents_router, prefix="/api/agents")
|
||
|
|
app.include_router(kanban_router, prefix="/api/kanban")
|
||
|
|
app.include_router(usage_router, prefix="/api/usage")
|
||
|
|
app.include_router(system_router, prefix="/api/system")
|
||
|
|
app.include_router(a2a_router, prefix="/api/a2a")
|
||
|
|
app.dependency_overrides[get_db] = _fake_db
|
||
|
|
|
||
|
|
# Stub the per-route services so the gate is the only variable.
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"roboco.api.routes.agents.get_agent_service",
|
||
|
|
lambda _db: MagicMock(list_agents=AsyncMock(return_value=[])),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"roboco.api.routes.kanban.get_kanban_service",
|
||
|
|
lambda _db: MagicMock(get_dev_board=AsyncMock(return_value={})),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"roboco.api.routes.usage.get_usage_service",
|
||
|
|
lambda _db: MagicMock(summary=AsyncMock(return_value={})),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"roboco.api.routes.system.RateLimitStateTracker.list_rate_limited_providers",
|
||
|
|
AsyncMock(return_value={}),
|
||
|
|
)
|
||
|
|
async with AsyncClient(
|
||
|
|
transport=ASGITransport(app=app), base_url="http://test"
|
||
|
|
) as client:
|
||
|
|
yield client
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("method", "path"),
|
||
|
|
[
|
||
|
|
("GET", "/api/agents"),
|
||
|
|
("GET", "/api/kanban/dev/backend"),
|
||
|
|
("GET", "/api/usage/summary?period=24h"),
|
||
|
|
("GET", "/api/system/rate-limits"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_panel_read_routes_reject_no_credential_under_cloud_auth(
|
||
|
|
gated_client: AsyncClient,
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
method: str,
|
||
|
|
path: str,
|
||
|
|
) -> None:
|
||
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
||
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
||
|
|
r = (
|
||
|
|
await gated_client.get(path)
|
||
|
|
if method == "GET"
|
||
|
|
else await gated_client.post(path)
|
||
|
|
)
|
||
|
|
assert r.status_code == _HTTP_401
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a2a_tasks_reject_no_credential_under_cloud_auth(
|
||
|
|
gated_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
"""/api/a2a/tasks is gated with get_agent_context (any authenticated agent)."""
|
||
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
||
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
||
|
|
r = await gated_client.get("/api/a2a/tasks")
|
||
|
|
assert r.status_code == _HTTP_401
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a2a_task_by_id_rejects_no_credential_under_cloud_auth(
|
||
|
|
gated_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
||
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
||
|
|
r = await gated_client.get("/api/a2a/tasks/t-1")
|
||
|
|
assert r.status_code == _HTTP_401
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_agents_accepts_valid_token_under_cloud_auth(
|
||
|
|
gated_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
||
|
|
r = await gated_client.get(
|
||
|
|
"/api/agents", headers={"X-Agent-Token": issue_panel_token()}
|
||
|
|
)
|
||
|
|
assert r.status_code == _HTTP_200
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_agents_dev_mode_no_token_passes(
|
||
|
|
gated_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", False)
|
||
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
||
|
|
r = await gated_client.get("/api/agents")
|
||
|
|
assert r.status_code == _HTTP_200
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a2a_discovery_endpoints_stay_ungated(
|
||
|
|
gated_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
"""Discovery surfaces (/.well-known, /a2a/agents[/{id}/card]) stay open."""
|
||
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.api.routes.a2a.A2AService.discover_agents",
|
||
|
|
new=AsyncMock(return_value=[]),
|
||
|
|
),
|
||
|
|
patch(
|
||
|
|
"roboco.api.routes.a2a.A2AService.build_agent_card",
|
||
|
|
new=AsyncMock(return_value=None),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
r1 = await gated_client.get("/api/a2a/agents")
|
||
|
|
r2 = await gated_client.get("/api/a2a/agents/be-dev-1/card")
|
||
|
|
assert r1.status_code == _HTTP_200
|
||
|
|
# Ungated => not 401 (404 from the None card is fine — the gate didn't fire).
|
||
|
|
assert r2.status_code != _HTTP_401
|