test/openagents/agents_test.exs

main at 58e6347eeb72 · 6 KB

defmodule OpenAgents.AgentsTest do
  use OpenAgents.DataCase, async: false

  alias OpenAgents.Accounts
  alias OpenAgents.Agents
  alias OpenAgents.Agents.AgentToken
  alias OpenAgents.Repo

  test "registers an agent and exposes its credential only at registration" do
    assert {:ok, agent, credential} =
             Agents.register(%{
               "handle" => "release-bot",
               "display_name" => "Release bot",
               "registration_ip" => "192.0.2.10"
             })

    assert agent.handle == "release-bot"
    assert String.starts_with?(credential, "oa_agent_")
    assert {:ok, authenticated, token} = Agents.authenticate(credential)
    assert authenticated.id == agent.id
    assert token.scopes == ["agent:participate"]
    refute Repo.get_by(AgentToken, id: token.id).token_digest == credential
  end

  test "normalizes handles and rejects collisions, reserved, and confusable values" do
    assert {:ok, agent, _credential} =
             Agents.register(%{
               handle: "Build-Bot",
               display_name: "Build bot",
               registration_ip: "192.0.2.11"
             })

    assert agent.handle == "build-bot"

    assert {:error, :handle_taken} =
             Agents.register(%{
               handle: "build-bot",
               display_name: "Another bot",
               registration_ip: "192.0.2.12"
             })

    assert {:error, :confusable_handle} =
             Agents.register(%{
               handle: "12345",
               display_name: "Numeric bot",
               registration_ip: "192.0.2.13"
             })

    assert {:error, :confusable_handle} =
             Agents.register(%{
               handle: "build--bot",
               display_name: "Malformed bot",
               registration_ip: "192.0.2.14"
             })
  end

  test "suspension blocks authentication and credential minting" do
    {:ok, agent, credential} =
      Agents.register(%{
        handle: "suspendable-bot",
        display_name: "Suspendable bot",
        registration_ip: "192.0.2.15"
      })

    assert {:ok, suspended} = Agents.suspend(agent, "abuse review")
    assert suspended.status == "suspended"
    assert {:error, :invalid_agent_credential} = Agents.authenticate(credential)
    assert {:error, :agent_suspended} = Agents.mint_credential(suspended)
    assert {:ok, reinstated} = Agents.reinstate(suspended)
    assert {:ok, _agent, _token} = Agents.authenticate(credential)
    assert reinstated.status == "active"
  end

  test "link lifecycle is scoped to the agent and user" do
    {:ok, agent, _credential} =
      Agents.register(%{
        handle: "linkable-bot",
        display_name: "Linkable bot",
        registration_ip: "192.0.2.16"
      })

    {:ok, user} =
      Accounts.upsert_github_user(%{
        github_id: 991_016,
        github_login: "link-reviewer",
        github_avatar_url: "https://avatars.githubusercontent.com/u/991016?v=4"
      })

    assert {:ok, pending} = Agents.request_link(agent, user)
    assert pending.status == "pending"
    assert [listed] = Agents.list_pending_links(user)
    assert listed.id == pending.id

    assert {:ok, linked} = Agents.accept_link(user, pending.id)
    assert linked.status == "linked"
    assert {:ok, unlinked} = Agents.unlink(user, linked.id)
    assert unlinked.status == "unlinked"
    assert unlinked.agent_id == agent.id
  end

  test "agent can unlink a link and request it again after rejection or unlink" do
    {:ok, agent, _credential} =
      Agents.register(%{
        handle: "relinkable-bot",
        display_name: "Relinkable bot",
        registration_ip: "192.0.2.18"
      })

    {:ok, user} =
      Accounts.upsert_github_user(%{
        github_id: 991_018,
        github_login: "relink-reviewer",
        github_avatar_url: "https://avatars.githubusercontent.com/u/991018?v=4"
      })

    assert {:ok, pending} = Agents.request_link(agent, user)
    assert {:ok, rejected} = Agents.reject_link(user, pending.id)
    assert rejected.status == "rejected"
    assert {:ok, requested_again} = Agents.request_link(agent, user)
    assert requested_again.id == pending.id
    assert requested_again.status == "pending"
    assert {:ok, linked} = Agents.accept_link(user, requested_again.id)
    assert {:ok, unlinked} = Agents.unlink(agent, user)
    assert unlinked.id == linked.id
    assert unlinked.status == "unlinked"
    assert {:ok, requested_after_unlink} = Agents.request_link(agent, user)
    assert requested_after_unlink.id == linked.id
    assert requested_after_unlink.status == "pending"
  end

  test "rotates credentials while preserving the old credential" do
    {:ok, agent, old_credential} =
      Agents.register(%{
        handle: "rotating-bot",
        display_name: "Rotating bot",
        registration_ip: "192.0.2.19"
      })

    assert {:ok, token, new_credential} = Agents.mint_credential(agent, %{"name" => "rotated"})
    assert DateTime.compare(token.expires_at, DateTime.utc_now()) == :gt
    assert {:ok, old_agent, _old_token} = Agents.authenticate(old_credential)
    assert {:ok, new_agent, _new_token} = Agents.authenticate(new_credential)
    assert old_agent.id == agent.id
    assert new_agent.id == agent.id

    {:ok, _same_agent, old_token} = Agents.authenticate(old_credential)
    expired_at = DateTime.add(DateTime.utc_now(), -1, :second)

    old_token
    |> Ecto.Changeset.change(
      inserted_at: DateTime.add(expired_at, -1, :second),
      expires_at: expired_at
    )
    |> Repo.update!()

    assert {:error, :invalid_agent_credential} = Agents.authenticate(old_credential)
    assert {:ok, _same_agent, _token} = Agents.authenticate(new_credential)
  end

  test "refuses overlong registration fields without truncating" do
    assert {:error, :display_name_too_long} =
             Agents.register(%{
               handle: "long-name-bot",
               display_name: String.duplicate("x", 256),
               registration_ip: "192.0.2.20"
             })

    assert {:error, :description_too_long} =
             Agents.register(%{
               handle: "long-description-bot",
               display_name: "Long description bot",
               description: String.duplicate("x", 4_001),
               registration_ip: "192.0.2.21"
             })
  end

  test "agent credentials cannot authenticate with a human scope" do
    {:ok, _agent, credential} =
      Agents.register(%{
        handle: "scope-limited-bot",
        display_name: "Scope-limited bot",
        registration_ip: "192.0.2.17"
      })

    assert {:error, :invalid_agent_credential} = Agents.authenticate(credential, "forge:write")
  end
end