test/openagents_web/controllers/api_token_controller_test.exs

58e6347eeb72 · 3 KB

defmodule OpenAgentsWeb.ApiTokenControllerTest do
  use OpenAgentsWeb.ConnCase, async: true
  import Ecto.Query

  alias OpenAgents.{ApiTokens, Repo}

  test "a browser-authenticated owner issues a token once and can revoke it", %{conn: conn} do
    user = github_user("api-token-controller-owner")
    browser = Plug.Test.init_test_session(conn, %{"user_id" => user.id})

    issued =
      post(browser, ~p"/api/tokens", %{
        name: "local CLI",
        scopes: ["forge:write"],
        lifetime_days: 30
      })

    assert get_resp_header(issued, "cache-control") == ["no-store"]
    assert %{"token" => plaintext, "credential" => %{"id" => id}} = json_response(issued, 201)
    assert String.starts_with?(plaintext, "oa_pat_")
    assert {:ok, ^user, _token} = ApiTokens.authenticate(plaintext, "forge:write")

    listed = issued |> recycle() |> get(~p"/api/tokens")
    assert [%{"id" => ^id}] = json_response(listed, 200)["tokens"]
    refute inspect(json_response(listed, 200)) =~ plaintext

    revoked = listed |> recycle() |> delete(~p"/api/tokens/#{id}")
    assert json_response(revoked, 200)["credential"]["revoked_at"]
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "forge:write")
  end

  test "forge mutations refuse missing, malformed, expired, and revoked credentials", %{
    conn: conn
  } do
    path = ~p"/api/v1/repos/OpenAgentsInc/openagents.com/issues"

    missing = conn |> delete_req_header("authorization") |> post(path, %{title: "denied"})
    assert assert_api_error(missing, 401, "unauthenticated")["error"] == "invalid_api_token"

    malformed =
      conn
      |> put_req_header("authorization", "Bearer not-a-token")
      |> post(path, %{title: "denied"})

    assert assert_api_error(malformed, 401, "unauthenticated")["error"] == "invalid_api_token"

    user = github_user("api-token-denial-owner")

    assert {:ok, token, plaintext} =
             ApiTokens.create(user, %{
               name: "denial cases",
               scopes: ["forge:write"],
               lifetime_days: 1
             })

    backdated = DateTime.add(DateTime.utc_now(), -2, :day)

    Repo.update_all(from(t in OpenAgents.ApiTokens.ApiToken, where: t.id == ^token.id),
      set: [inserted_at: backdated]
    )

    expired_token =
      Repo.get!(OpenAgents.ApiTokens.ApiToken, token.id)
      |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
      |> Repo.update!()

    expired =
      conn
      |> put_req_header("authorization", "Bearer " <> plaintext)
      |> post(path, %{title: "denied"})

    assert assert_api_error(expired, 401, "unauthenticated")["error"] == "invalid_api_token"

    fresh_token =
      expired_token
      |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), 1, :day))
      |> Repo.update!()

    assert {:ok, _revoked} = ApiTokens.revoke(user, fresh_token.id)

    revoked =
      conn
      |> put_req_header("authorization", "Bearer " <> plaintext)
      |> post(path, %{title: "denied"})

    assert assert_api_error(revoked, 401, "unauthenticated")["error"] == "invalid_api_token"
  end

  test "browser credential mutations remain CSRF protected" do
    user = github_user("api-token-csrf-owner")

    browser =
      build_conn()
      |> init_test_session(%{"user_id" => user.id})
      |> get(~p"/settings/api-tokens")

    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/tokens", %{
        name: "must not exist",
        scopes: ["forge:write"],
        lifetime_days: 1
      })
    end
  end

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