Newer
Older
gnexus-tasks / backend / tests / test_agent_mandate.py
"""Тесты мандата ИИ-агента: доступность задачи, гейт агента (ТЗ 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