"""Тесты мандата ИИ-агента: доступность задачи, гейт агента (ТЗ 3.20)."""
from types import SimpleNamespace
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app import mcp_server
from app.actor import VIA_API, agent_actor
from app.dependencies import actor_of, require_actor, require_user
from tests.conftest import agent_ctx, mcp_ctx # type: ignore[attr-defined]
def _create(client: TestClient, **payload: Any) -> dict[str, Any]:
resp = client.post("/api/tasks", json={"title": "задача", **payload})
assert resp.status_code == 200, resp.text
got = client.get(f"/api/tasks/{resp.json()['id']}")
assert got.status_code == 200, got.text
return got.json()
def _as_agent(client: TestClient, name: str = "Нави") -> Any:
"""Действие REST — агентское: подменяем актора, авторизация остаётся.
Заголовок `X-Actor` разбирает actor_of() по пути; здесь подменён именно актор,
поэтому правило заголовка проверяется отдельным тестом.
"""
overrides = client.app.dependency_overrides # type: ignore[attr-defined]
saved = overrides.get(require_actor)
overrides[require_actor] = lambda: agent_actor(name, 7, "Владелец", VIA_API)
return saved
def _restore_agent(client: TestClient, saved: Any) -> None:
overrides = client.app.dependency_overrides # type: ignore[attr-defined]
if saved is None:
overrides.pop(require_actor, None)
else:
overrides[require_actor] = saved
def test_flag_defaults_off(client: TestClient) -> None:
task = _create(client)
assert task["ai_eligible"] is False
assert task["accept_state"] is None
assert task["claimed_by"] is None
def test_owner_sets_flag_by_patch(client: TestClient) -> None:
task = _create(client)
resp = client.patch(f"/api/tasks/{task['id']}", json={"ai_eligible": True})
assert resp.status_code == 200, resp.text
assert resp.json()["ai_eligible"] is True
def test_agent_needs_flag(client: TestClient) -> None:
task = _create(client)
flagged = _create(client, ai_eligible=True)
saved = _as_agent(client)
try:
denied = client.patch(f"/api/tasks/{task['id']}", json={"priority": 5})
assert denied.status_code == 403, denied.text
allowed = client.patch(f"/api/tasks/{flagged['id']}", json={"priority": 5})
assert allowed.status_code == 200, allowed.text
finally:
_restore_agent(client, saved)
def test_owner_is_not_limited(client: TestClient) -> None:
"""Владелец (сессия SSO) правит любую задачу — флаг для него не преграда."""
task = _create(client)
assert client.patch(f"/api/tasks/{task['id']}", json={"priority": 5}).status_code == 200
def test_agent_cannot_grant_itself_the_flag(client: TestClient) -> None:
flagged = _create(client, ai_eligible=True)
saved = _as_agent(client)
try:
# Ни на помеченной задаче, ни с объявлением «действую как владелец»
denied = client.patch(f"/api/tasks/{flagged['id']}", json={"ai_eligible": False})
assert denied.status_code == 403, denied.text
denied2 = client.patch(
f"/api/tasks/{flagged['id']}",
json={"ai_eligible": False},
headers={"X-Actor": "user"},
)
assert denied2.status_code == 403, denied2.text
finally:
_restore_agent(client, saved)
def test_agent_may_create_flagged_task(client: TestClient) -> None:
"""Своя задача — другое дело: при создании агент вправе попросить мандат."""
saved = _as_agent(client)
try:
task = _create(client, ai_eligible=True)
assert task["ai_eligible"] is True
assert task["created_by_kind"] == "agent"
assert task["created_by_name"] == "Нави"
finally:
_restore_agent(client, saved)
def test_list_filters_by_flag_and_author(client: TestClient) -> None:
flagged = _create(client, ai_eligible=True)
plain = _create(client)
assert [t["id"] for t in client.get("/api/tasks", params={"ai_eligible": True}).json()] == [
flagged["id"]
]
owners = [t["id"] for t in client.get("/api/tasks", params={"created_by_kind": "user"}).json()]
assert set(owners) == {flagged["id"], plain["id"]}
assert client.get("/api/tasks", params={"created_by_kind": "agent"}).json() == []
def test_bearer_token_is_agent_until_declared(client: TestClient) -> None:
"""REST с Bearer: по умолчанию агент, `X-Actor: user` объявляет действие владельца."""
token = client.post("/api/mcp-tokens", json={"label": "Нави"}).json()["token"]
plain = _create(client)
saved = client.app.dependency_overrides.pop(require_user, None) # type: ignore[attr-defined]
auth = {"Authorization": f"Bearer {token}"}
try:
denied = client.patch(f"/api/tasks/{plain['id']}", json={"priority": 5}, headers=auth)
assert denied.status_code == 403, denied.text
allowed = client.patch(
f"/api/tasks/{plain['id']}",
json={"priority": 5},
headers={**auth, "X-Actor": "user"},
)
assert allowed.status_code == 200, allowed.text
# Создано агентом (расширение объявило бы себя владельцем) — виден актор
mine = client.post("/api/tasks", json={"title": "от агента"}, headers=auth).json()
got = client.get(f"/api/tasks/{mine['id']}", headers=auth).json()
assert (got["created_by_kind"], got["created_by_name"]) == ("agent", "Нави")
finally:
if saved is not None:
client.app.dependency_overrides[require_user] = saved # type: ignore[attr-defined]
def test_x_actor_header_rule() -> None:
"""Правило actor_of: агент за заголовком не читается, «user» делает его владельцем."""
state = SimpleNamespace(gntodo_actor=agent_actor("Нави", 7, "Владелец", VIA_API))
assert actor_of(SimpleNamespace(state=state, headers={})).is_agent
declared = actor_of(SimpleNamespace(state=state, headers={"x-actor": "user"}))
assert (declared.kind, declared.name, declared.via) == ("user", "Владелец", "api")
def test_mcp_tool_refuses_unflagged_task(client: TestClient) -> None:
ctx = agent_ctx(label="Нави")
plain = mcp_server.create_task("без флага", ctx=ctx)
with pytest.raises(ValueError, match="not available to an AI agent"):
mcp_server.update_task(plain["id"], priority=5, ctx=ctx)
# ... и то же самое с is_user=true проходит: так действует владелец
assert mcp_server.update_task(plain["id"], priority=5, is_user=True, ctx=ctx)["priority"] == 5
def test_mcp_flag_is_owner_only(client: TestClient) -> None:
ctx = agent_ctx(label="Нави")
task = mcp_server.create_task("своя", ai_eligible=True, ctx=ctx)
with pytest.raises(ValueError, match="ai_eligible is set by the owner"):
mcp_server.update_task(task["id"], ai_eligible=False, ctx=ctx)
# Владелец (is_user=true) флаг снять вправе
closed = mcp_server.update_task(task["id"], ai_eligible=False, is_user=True, ctx=ctx)
assert closed["ai_eligible"] is False
def test_mcp_compacts_expose_mandate(client: TestClient) -> None:
ctx = mcp_ctx()
created = mcp_server.create_task("задача", ai_eligible=True, ctx=ctx)
full = mcp_server.get_task(created["id"], ctx=ctx)
assert full["ai_eligible"] is True
assert full["created_by_kind"] == "user"
assert full["accept_state"] is None
assert full["claimed_by"] is None