# ruff: noqa: INP001 from __future__ import annotations import json from uuid import UUID, uuid4 import pytest from fastapi import APIRouter, Depends, FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from sqlmodel import SQLModel from sqlmodel.ext.asyncio.session import AsyncSession from app.api.agent import router as agent_router from app.api.deps import get_board_or_404 from app.core.agent_tokens import hash_agent_token from app.db.session import get_session from app.models.agents import Agent from app.models.board_webhook_payloads import BoardWebhookPayload from app.models.board_webhooks import BoardWebhook from app.models.boards import Board from app.models.gateways import Gateway from app.models.organizations import Organization async def _make_engine() -> AsyncEngine: engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.connect() as conn, conn.begin(): await conn.run_sync(SQLModel.metadata.create_all) return engine def _build_test_app(session_maker: async_sessionmaker[AsyncSession]) -> FastAPI: app = FastAPI() api_v1 = APIRouter(prefix="/api/v1") api_v1.include_router(agent_router) app.include_router(api_v1) async def _override_get_session() -> AsyncSession: async with session_maker() as session: yield session async def _override_get_board_or_404( board_id: str, session: AsyncSession = Depends(get_session), ) -> Board: board = await Board.objects.by_id(UUID(board_id)).first(session) if board is None: from fastapi import HTTPException, status raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) return board app.dependency_overrides[get_session] = _override_get_session app.dependency_overrides[get_board_or_404] = _override_get_board_or_404 return app async def _seed_payload( session: AsyncSession, *, payload_value: dict[str, object] | list[object] | str | int | float | bool | None = None, ) -> tuple[str, Board, BoardWebhook, BoardWebhookPayload]: token = "test-agent-token-" + uuid4().hex token_hash = hash_agent_token(token) organization_id = uuid4() gateway_id = uuid4() board_id = uuid4() webhook_id = uuid4() agent_id = uuid4() payload_id = uuid4() session.add(Organization(id=organization_id, name=f"org-{organization_id}")) session.add( Gateway( id=gateway_id, organization_id=organization_id, name="gateway", url="https://gateway.example.local", workspace_root="/tmp/workspace", ), ) board = Board( id=board_id, organization_id=organization_id, gateway_id=gateway_id, name="Board", slug="board", ) session.add(board) session.add( Agent( id=agent_id, board_id=board_id, gateway_id=gateway_id, name="Lead Agent", status="online", is_board_lead=True, openclaw_session_id="agent:lead:session", agent_token_hash=token_hash, ), ) webhook = BoardWebhook( id=webhook_id, board_id=board_id, description="Triage payload", enabled=True, ) session.add(webhook) payload = BoardWebhookPayload( id=payload_id, board_id=board_id, webhook_id=webhook_id, payload=payload_value or {"event": "push", "ref": "refs/heads/master"}, headers={"x-github-event": "push"}, content_type="application/json", source_ip="127.0.0.1", ) session.add(payload) await session.commit() return token, board, webhook, payload @pytest.mark.asyncio async def test_agent_can_fetch_webhook_payload() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) app = _build_test_app(session_maker) async with session_maker() as session: token, board, webhook, payload = await _seed_payload(session) try: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get( f"/api/v1/agent/boards/{board.id}/webhooks/{webhook.id}/payloads/{payload.id}", headers={"X-Agent-Token": token}, ) assert response.status_code == 200 body = response.json() assert body["id"] == str(payload.id) assert body["board_id"] == str(board.id) assert body["webhook_id"] == str(webhook.id) assert body["payload"] == {"event": "push", "ref": "refs/heads/master"} assert body["headers"]["x-github-event"] == "push" finally: await engine.dispose() @pytest.mark.asyncio async def test_agent_payload_read_rejects_invalid_token() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) app = _build_test_app(session_maker) async with session_maker() as session: _token, board, webhook, payload = await _seed_payload(session) try: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get( f"/api/v1/agent/boards/{board.id}/webhooks/{webhook.id}/payloads/{payload.id}", headers={"X-Agent-Token": "invalid"}, ) assert response.status_code == 401 finally: await engine.dispose() @pytest.mark.asyncio async def test_agent_payload_read_truncates_json_preview_with_ellipsis() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) app = _build_test_app(session_maker) async with session_maker() as session: payload_value: dict[str, object] = {"event": "push", "ref": "refs/heads/master"} token, board, webhook, payload = await _seed_payload(session, payload_value=payload_value) max_chars = 12 raw = json.dumps(payload_value, ensure_ascii=True) expected_preview = f"{raw[: max_chars - 3]}..." try: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get( f"/api/v1/agent/boards/{board.id}/webhooks/{webhook.id}/payloads/{payload.id}", headers={"X-Agent-Token": token}, params={"max_chars": max_chars}, ) assert response.status_code == 200 body = response.json() assert body["payload"] == expected_preview finally: await engine.dispose() @pytest.mark.asyncio async def test_agent_payload_read_truncates_string_preview_without_json_quoting() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) app = _build_test_app(session_maker) async with session_maker() as session: token, board, webhook, payload = await _seed_payload(session, payload_value="abcdef") try: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get( f"/api/v1/agent/boards/{board.id}/webhooks/{webhook.id}/payloads/{payload.id}", headers={"X-Agent-Token": token}, params={"max_chars": 4}, ) assert response.status_code == 200 body = response.json() assert body["payload"] == "a..." finally: await engine.dispose() @pytest.mark.asyncio async def test_agent_payload_read_rejects_cross_board_access() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) app = _build_test_app(session_maker) async with session_maker() as session: token, board, webhook, payload = await _seed_payload(session) # Second board + payload that should be inaccessible to the first board agent. organization_id = uuid4() gateway_id = uuid4() other_board = Board( id=uuid4(), organization_id=organization_id, gateway_id=gateway_id, name="Other", slug="other", ) session.add(Organization(id=organization_id, name=f"org-{organization_id}")) session.add( Gateway( id=gateway_id, organization_id=organization_id, name="gateway", url="https://gateway.example.local", workspace_root="/tmp/workspace", ), ) session.add(other_board) other_webhook = BoardWebhook( id=uuid4(), board_id=other_board.id, description="Other webhook", enabled=True, ) session.add(other_webhook) other_payload = BoardWebhookPayload( id=uuid4(), board_id=other_board.id, webhook_id=other_webhook.id, payload={"event": "push"}, ) session.add(other_payload) await session.commit() try: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get( f"/api/v1/agent/boards/{other_board.id}/webhooks/{other_webhook.id}/payloads/{other_payload.id}", headers={"X-Agent-Token": token}, ) assert response.status_code == 403 finally: await engine.dispose()