mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Add a CEO-bound, header-token-only gate (require_panel_token) at the route level of the prompter_live + secretary_live bridges, which were the only panel-facing API surface that ran unauthenticated. It mirrors the WS _require_panel_token and _check_agent_auth_token contracts: in dev (ROBOCO_AGENT_AUTH_REQUIRED unset) a missing token is allowed; a presented-but-forged token is rejected even in dev; in prod nginx already injects the CEO-signed X-Agent-Token on /api/ for GET + POST, so the SSE stream (EventSource can't set headers) and the POSTs are now checked instead of anonymous. Applied to start/stream/status/messages/stop on both routers; preview_live_batch switched from CurrentAgentContext+noqa to the route-level gate (genuinely auth-only). confirm/confirm-batch/re-interview keep CurrentAgentContext (they use agent.identity). The container->relay /events callback is intentionally left ungated (internal Docker network, opaque session id) — gated by a test sentinel so Option B (spawn+SDK token wiring) is a deliberate future decision. No panel/nginx/spawn/SDK changes; master merge invariant untouched. 22 new TDD auth tests, 492 api tests green.
277 lines
9.4 KiB
Python
277 lines
9.4 KiB
Python
"""Token enforcement on the live intake chat (Phase 5).
|
|
|
|
The panel-facing ``prompter_live`` routes used to take no auth dependency — the
|
|
SSE stream carried no identity (browser ``EventSource`` can't set headers) and
|
|
``start``/``status``/``messages``/``stop`` accepted anonymous calls. The fix
|
|
adds a CEO-bound, header-token-only gate (``require_panel_token``) at the route
|
|
level: in prod nginx injects the CEO-signed ``X-Agent-Token`` on ``/api/``, and
|
|
in dev a missing token is allowed while a presented-but-forged one is still
|
|
rejected (matching ``_check_agent_auth_token`` and the WS gate).
|
|
|
|
These tests mount the router on a bare ``FastAPI()`` (no ``setup_middleware``),
|
|
so the gate must raise ``HTTPException(401)`` — the same shape as the a2a auth
|
|
tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from http import HTTPStatus
|
|
from typing import TYPE_CHECKING, Any
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
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
|
|
from roboco.api.routes.prompter_live import router as prompter_live_router
|
|
from roboco.db.base import get_db
|
|
from roboco.services import prompter_live
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
_SECRET = "test-secret-for-prompter-live-auth"
|
|
_HTTP_401 = HTTPStatus.UNAUTHORIZED
|
|
|
|
|
|
class _FakeOrchestrator:
|
|
"""Records spawn/reap calls; stands in for the real orchestrator singleton."""
|
|
|
|
def __init__(self) -> None:
|
|
self.spawned: list[dict[str, Any]] = []
|
|
self.reaped: list[str] = []
|
|
|
|
async def start_intake_session(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
project_slug: str | None = None,
|
|
product_id: str | None = None,
|
|
project_ids: list[str] | None = None,
|
|
initial_message: str | None = None,
|
|
) -> None:
|
|
self.spawned.append(
|
|
{
|
|
"session_id": session_id,
|
|
"project_slug": project_slug,
|
|
"product_id": product_id,
|
|
"project_ids": project_ids,
|
|
"initial_message": initial_message,
|
|
}
|
|
)
|
|
|
|
async def reap_intake_session(self, session_id: str) -> None:
|
|
self.reaped.append(session_id)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def auth_client(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> AsyncIterator[AsyncClient]:
|
|
"""Mounted router + fake orchestrator + empty registry; no auth env set.
|
|
|
|
Each test monkeypatches ``ROBOCO_AGENT_AUTH_SECRET`` and
|
|
``ROBOCO_AGENT_AUTH_REQUIRED`` to pick dev vs strict mode. The registry is
|
|
empty so the SSE stream over an unknown session yields nothing (200),
|
|
``status`` reports dead, ``messages`` 404s, and ``/events`` reports
|
|
``pushed: false`` — all non-401, which is what the "gate passed" assertions
|
|
need.
|
|
"""
|
|
orch = _FakeOrchestrator()
|
|
monkeypatch.setattr(deps._ServiceHolder, "orchestrator", orch)
|
|
|
|
def container_handler(_req: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
mock_client = httpx.AsyncClient(transport=httpx.MockTransport(container_handler))
|
|
registry = prompter_live.PrompterLiveRegistry(http_client=mock_client)
|
|
prompter_live._RegistryHolder.instance = registry
|
|
|
|
async def _fake_db() -> AsyncIterator[object]:
|
|
yield object()
|
|
|
|
app = FastAPI()
|
|
app.include_router(prompter_live_router, prefix="/api/prompter")
|
|
app.dependency_overrides[get_db] = _fake_db
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
yield client
|
|
|
|
prompter_live._RegistryHolder.instance = None
|
|
await mock_client.aclose()
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def _strict(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
|
def _dev(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
|
|
|
|
|
def _start_body() -> dict[str, Any]:
|
|
return {"product_id": str(uuid4()), "initial_message": "build X"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /live/start
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_rejects_missing_token_when_required(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_strict(monkeypatch)
|
|
r = await auth_client.post("/api/prompter/live/start", json=_start_body())
|
|
assert r.status_code == _HTTP_401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_rejects_forged_token_when_required(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_strict(monkeypatch)
|
|
r = await auth_client.post(
|
|
"/api/prompter/live/start",
|
|
json=_start_body(),
|
|
headers={"X-Agent-Token": "forged-not-a-real-hmac"},
|
|
)
|
|
assert r.status_code == _HTTP_401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_accepts_valid_panel_token(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_strict(monkeypatch)
|
|
r = await auth_client.post(
|
|
"/api/prompter/live/start",
|
|
json=_start_body(),
|
|
headers={"X-Agent-Token": issue_panel_token()},
|
|
)
|
|
assert r.status_code == HTTPStatus.CREATED # gate passed -> 201
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_rejects_forged_token_even_in_dev(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A presented-but-forged token is rejected even in header-trust mode."""
|
|
_dev(monkeypatch)
|
|
r = await auth_client.post(
|
|
"/api/prompter/live/start",
|
|
json=_start_body(),
|
|
headers={"X-Agent-Token": "forged-not-a-real-hmac"},
|
|
)
|
|
assert r.status_code == _HTTP_401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_dev_mode_missing_token_succeeds(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_dev(monkeypatch)
|
|
r = await auth_client.post("/api/prompter/live/start", json=_start_body())
|
|
assert r.status_code == HTTPStatus.CREATED # dev flow preserved
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /live/{id}/stream (SSE)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_rejects_missing_token_when_required(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_strict(monkeypatch)
|
|
r = await auth_client.get("/api/prompter/live/unknown/stream")
|
|
assert r.status_code == _HTTP_401 # 401 before the EventSourceResponse starts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_accepts_valid_panel_token(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_strict(monkeypatch)
|
|
r = await auth_client.get(
|
|
"/api/prompter/live/unknown/stream",
|
|
headers={"X-Agent-Token": issue_panel_token()},
|
|
)
|
|
assert r.status_code == HTTPStatus.OK # unknown session -> empty stream -> 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /live/{id}/status, /messages, /stop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("method", "path", "json"),
|
|
[
|
|
("GET", "/api/prompter/live/unknown/status", None),
|
|
("POST", "/api/prompter/live/unknown/messages", {"text": "hi"}),
|
|
("POST", "/api/prompter/live/sess/stop", None),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_status_send_stop_reject_missing_token_when_required(
|
|
auth_client: AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
method: str,
|
|
path: str,
|
|
json: dict[str, Any] | None,
|
|
) -> None:
|
|
_strict(monkeypatch)
|
|
if method == "GET":
|
|
r = await auth_client.get(path)
|
|
else:
|
|
r = await auth_client.post(path, json=json)
|
|
assert r.status_code == _HTTP_401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /live/{id}/preview-batch — switched from CurrentAgentContext to the panel gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preview_batch_passes_with_valid_token_in_strict_mode(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_strict(monkeypatch)
|
|
r = await auth_client.post(
|
|
"/api/prompter/live/s1/preview-batch",
|
|
json={"drafts": [{"title": "A"}, {"title": "B"}]},
|
|
headers={"X-Agent-Token": issue_panel_token()},
|
|
)
|
|
# Gate passed -> 200 with waves (preview is pure compute; no session needed).
|
|
assert r.status_code == HTTPStatus.OK
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /live/{id}/events — container -> relay, intentionally UNGATED (scope sentinel)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_ungated_in_strict_mode(
|
|
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""``/events`` is the container->relay callback on the internal Docker
|
|
network (opaque session id). Option A leaves it ungated; this test pins
|
|
that decision so a future gating change can't land silently."""
|
|
_strict(monkeypatch)
|
|
r = await auth_client.post(
|
|
"/api/prompter/live/unknown/events", json={"kind": "text"}
|
|
)
|
|
assert r.status_code == HTTPStatus.OK # ungated -> 200 (pushed: false)
|
|
assert r.json() == {"pushed": False}
|