test/openagents_web/controllers/computers_controller_test.exs

58e6347eeb72 · 8 KB

defmodule OpenAgentsWeb.ComputersControllerTest do
  use OpenAgentsWeb.ConnCase, async: false
  alias OpenAgents.Computer
  alias OpenAgents.Machines
  alias OpenAgents.Machines.Pairing
  alias OpenAgents.Repo

  @pairing_params %{
    "name" => "api-canary",
    "tier" => "curated",
    "platform" => "linux-x64",
    "agent_version" => "0.4.0",
    "roots" => ["/tmp/openagents-api-canary"]
  }

  test "the API drives pairing, claim, presence, inventory, and revoke without exposing tokens",
       %{
         conn: conn
       } do
    user = github_user("computers-api-lifecycle")
    created = create_pairing(conn)

    approved =
      build_conn()
      |> authenticated_api(user)
      |> post(~p"/api/computers/pairings/#{created["pairing_id"]}/approve", %{
        "code" => created["code"]
      })
      |> json_response(200)

    machine_id = approved["computer"]["id"]
    assert approved["computer"]["status"] == "active"
    assert approved["computer"]["online"] == false
    refute inspect(approved) =~ "smct_"

    claimed =
      build_conn()
      |> put_req_header("x-pairing-secret", created["poll_secret"])
      |> get(~p"/controller/pairings/#{created["pairing_id"]}")
      |> json_response(200)

    token = claimed["token"]
    assert {:ok, %{id: ^machine_id}} = Machines.authenticate_token(token)

    {:ok, machine} = Machines.get_machine(user.id, machine_id)

    agents =
      for index <- 1..18 do
        %{
          "id" => "agent-#{index}",
          "version" => String.duplicate("v", 100),
          "source" => "operator",
          "auth_ready" => true,
          "model" => "gpt-5.6-sol",
          "reasoning_effort" => "medium",
          "mode" => "agent-full-access",
          "private_detail" => "not projected"
        }
      end

    assert {:ok, _machine} = Machines.store_probe(machine, %{"acp_agents" => agents})

    assert {:ok, _registration} = Computer.register(machine_id)
    on_exit(fn -> Computer.unregister(machine_id) end)

    inventory =
      build_conn()
      |> authenticated_api(user)
      |> get(~p"/api/computers")
      |> json_response(200)

    assert %{
             "schema" => "openagents.computers.v1",
             "pairing_enabled" => true,
             "computers" => [listed]
           } = inventory

    assert %{"id" => ^machine_id, "online" => true, "status" => "active"} = listed
    assert length(listed["acp_agents"]) == 16

    assert %{
             "model" => "gpt-5.6-sol",
             "reasoning_effort" => "medium",
             "mode" => "agent-full-access"
           } = hd(listed["acp_agents"])

    refute inspect(listed) =~ "private_detail"

    revoked =
      build_conn()
      |> authenticated_api(user)
      |> delete(~p"/api/computers/#{machine_id}")
      |> json_response(200)

    assert revoked["computer"]["status"] == "revoked"
    assert revoked["computer"]["online"] == false
    assert {:error, :machine_revoked} = Machines.authenticate_token(token)
  end

  test "approval binds the pairing id to its code and consumes the code once", %{conn: conn} do
    user = github_user("computers-api-binding")
    first = create_pairing(conn)
    second = create_pairing(build_conn())

    mismatch =
      build_conn()
      |> authenticated_api(user)
      |> post(~p"/api/computers/pairings/#{first["pairing_id"]}/approve", %{
        "code" => second["code"]
      })

    assert json_response(mismatch, 404)["error"] == "pairing_not_found"

    path = ~p"/api/computers/pairings/#{first["pairing_id"]}/approve"
    params = %{"code" => first["code"]}
    assert build_conn() |> authenticated_api(user) |> post(path, params) |> json_response(200)

    consumed = build_conn() |> authenticated_api(user) |> post(path, params)
    assert json_response(consumed, 409)["error"] == "pairing_consumed"
  end

  test "expired codes and disabled pairing return typed outcomes", %{conn: conn} do
    user = github_user("computers-api-errors")
    created = create_pairing(conn)

    Repo.get!(Pairing, created["pairing_id"])
    |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
    |> Repo.update!()

    expired =
      build_conn()
      |> authenticated_api(user)
      |> post(~p"/api/computers/pairings/#{created["pairing_id"]}/approve", %{
        "code" => created["code"]
      })

    assert json_response(expired, 410)["error"] == "pairing_expired"

    previous = Application.fetch_env!(:openagents, :computer_controller_enabled)
    Application.put_env(:openagents, :computer_controller_enabled, false)
    on_exit(fn -> Application.put_env(:openagents, :computer_controller_enabled, previous) end)

    disabled =
      build_conn()
      |> authenticated_api(user)
      |> post(~p"/api/computers/pairings/#{created["pairing_id"]}/approve", %{
        "code" => created["code"]
      })

    assert json_response(disabled, 404)["error"] == "computer_controller_disabled"
  end

  test "computer inventory and revocation are owner scoped", %{conn: conn} do
    owner = github_user("computers-api-owner")
    outsider = github_user("computers-api-outsider")
    created = create_pairing(conn)

    %{"computer" => %{"id" => machine_id}} =
      build_conn()
      |> authenticated_api(owner)
      |> post(~p"/api/computers/pairings/#{created["pairing_id"]}/approve", %{
        "code" => created["code"]
      })
      |> json_response(200)

    assert %{"computers" => []} =
             build_conn()
             |> authenticated_api(outsider)
             |> get(~p"/api/computers")
             |> json_response(200)

    response =
      build_conn()
      |> authenticated_api(outsider)
      |> delete(~p"/api/computers/#{machine_id}")

    assert json_response(response, 404)["error"] == "computer_not_found"
    assert {:ok, %{status: "active"}} = Machines.get_machine(owner.id, machine_id)
  end

  test "approval maps the active-computer capacity boundary" do
    user = github_user("computers-api-capacity")

    for _index <- 1..8 do
      created = create_pairing(build_conn())

      assert build_conn()
             |> authenticated_api(user)
             |> post(~p"/api/computers/pairings/#{created["pairing_id"]}/approve", %{
               "code" => created["code"]
             })
             |> json_response(200)
    end

    ninth = create_pairing(build_conn())

    response =
      build_conn()
      |> authenticated_api(user)
      |> post(~p"/api/computers/pairings/#{ninth["pairing_id"]}/approve", %{
        "code" => ninth["code"]
      })

    assert json_response(response, 409)["error"] == "computer_capacity_reached"
  end

  test "the lifecycle API requires an active browser session", %{conn: conn} do
    response =
      conn
      |> init_test_session(%{})
      |> put_req_header("accept", "application/json")
      |> get(~p"/api/computers")

    assert json_response(response, 401)["error"] == "authentication_required"
  end

  test "mutations require the browser session's CSRF token", %{conn: conn} do
    user = github_user("computers-api-csrf")
    created = create_pairing(conn)

    browser =
      build_conn()
      |> Plug.Test.init_test_session(%{"user_id" => user.id})
      |> get(~p"/computers")

    assert is_binary(get_session(browser, "_csrf_token"))

    assert_raise Plug.CSRFProtection.InvalidCSRFTokenError, fn ->
      browser
      |> recycle()
      |> enable_csrf_protection()
      |> put_req_header("accept", "application/json")
      |> put_req_header("x-csrf-token", "invalid")
      |> post(~p"/api/computers/pairings/#{created["pairing_id"]}/approve", %{
        "code" => created["code"]
      })
    end
  end

  defp create_pairing(conn) do
    conn
    |> put_req_header("accept", "application/json")
    |> post(~p"/controller/pairings", @pairing_params)
    |> json_response(200)
  end

  defp authenticated_api(conn, user) do
    csrf_token = Plug.CSRFProtection.get_csrf_token()

    conn
    |> Plug.Test.init_test_session(%{"user_id" => user.id})
    |> put_req_header("accept", "application/json")
    |> put_req_header("x-csrf-token", csrf_token)
  end

  defp enable_csrf_protection(conn) do
    %{conn | private: Map.delete(conn.private, :plug_skip_csrf_protection)}
  end
end