test/openagents_web/controllers/forum_api_controller_test.exs

main at 58e6347eeb72 · 17 KB

defmodule OpenAgentsWeb.ForumApiControllerTest do
  use OpenAgentsWeb.ConnCase, async: false

  alias OpenAgents.Forum
  alias OpenAgents.Accounts
  alias OpenAgents.Agents
  alias OpenAgents.Repo

  setup %{conn: conn} do
    {:ok, forum} =
      %Forum.Forum{}
      |> Forum.Forum.changeset(%{slug: "general", title: "General"})
      |> Repo.insert()

    {:ok, conn: conn, forum: forum}
  end

  defp topic(forum, attrs \\ %{}) do
    {:ok, topic} =
      Forum.create_topic(
        forum,
        Map.merge(
          %{
            title: "Hello world",
            slug: "hello-world",
            body_text: "First post body",
            idempotency_key: Ecto.UUID.generate(),
            actor_ref: "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20",
            actor_display_name: "Orrery",
            actor_slug: "orrery"
          },
          attrs
        )
      )

    topic
  end

  test "GET /api/v1/forum lists public boards", %{conn: conn} do
    conn = get(conn, ~p"/api/v1/forum")

    assert %{"boards" => [board]} = json_response(conn, 200)
    assert board["slug"] == "general"
    assert board["title"] == "General"
  end

  test "GET /api/v1/forum/topics lists a board's topics", %{conn: conn, forum: forum} do
    topic(forum)

    conn = get(conn, ~p"/api/v1/forum/topics?forum=general")

    assert %{"topics" => [t], "pagination" => pagination} = json_response(conn, 200)
    assert t["title"] == "Hello world"
    assert t["url"] =~ "/forum/t/"
    assert pagination["total"] == 1
  end

  test "GET /api/v1/forum/topics/:id returns the thread with posts", %{conn: conn, forum: forum} do
    topic = topic(forum)

    conn = get(conn, ~p"/api/v1/forum/topics/#{topic.id}")

    assert %{"topic" => t, "posts" => posts} = json_response(conn, 200)
    assert t["title"] == "Hello world"
    assert [%{"body_text" => "First post body"}] = posts
  end

  # A topic whose id the test chooses, so a prefix can be shared or unique on
  # purpose. Inserted without posts; resolution is what these tests read.
  defp topic_with_id(forum, id) do
    {:ok, topic} =
      %Forum.Topic{}
      |> Ecto.Changeset.change(%{
        id: id,
        forum_id: forum.id,
        idempotency_key: Ecto.UUID.generate(),
        slug: "topic-#{System.unique_integer([:positive])}",
        title: "Prefixed topic",
        actor_ref: "agent:agent_artanis",
        actor_display_name: "Artanis"
      })
      |> Repo.insert()

    topic
  end

  test "GET /api/v1/forum/topics/:id resolves a unique id prefix", %{conn: conn, forum: forum} do
    topic = topic_with_id(forum, "aaaabbbb-0000-4000-8000-000000000001")

    conn = get(conn, ~p"/api/v1/forum/topics/aaaabbbb")

    assert %{"topic" => t} = json_response(conn, 200)
    assert t["id"] == topic.id
  end

  test "GET /api/v1/forum/topics/:id answers 409 for an ambiguous prefix", %{
    conn: conn,
    forum: forum
  } do
    topic_with_id(forum, "88888888-4001-4001-8001-000000000001")
    topic_with_id(forum, "88888888-4002-4002-8002-000000000002")

    conn = get(conn, ~p"/api/v1/forum/topics/88888888")

    assert %{"error" => "ambiguous_id"} = json_response(conn, 409)
  end

  test "GET /api/v1/forum/topics/:id answers 404 for a prefix that matches nothing", %{conn: conn} do
    conn = get(conn, ~p"/api/v1/forum/topics/deadbeef")

    assert %{"error" => "not_found"} = json_response(conn, 404)
  end

  test "POST /api/v1/forum/topics creates a topic attributed to the token account", %{conn: conn} do
    conn =
      conn
      |> put_forge_api_token("forum-api-create")
      |> post(~p"/api/v1/forum/topics", %{
        forum: "general",
        title: "From the CLI",
        body_text: "Posted over the API"
      })

    assert response = json_response(conn, 201)
    assert response["topic"]["title"] == "From the CLI"
    assert hd(response["posts"])["author"]["is_agent"] == false
  end

  test "POST /api/v1/forum/topics requires authentication", %{conn: conn} do
    conn = post(conn, ~p"/api/v1/forum/topics", %{forum: "general", title: "x", body_text: "y"})

    assert json_response(conn, 401)
  end

  test "an unlinked agent can create a topic and reply with stable attribution", %{
    conn: conn,
    forum: forum
  } do
    {:ok, agent, credential} =
      Agents.register(%{
        handle: "forum-agent",
        display_name: "Forum agent",
        registration_ip: "192.0.2.40"
      })

    topic_conn =
      conn
      |> put_req_header("authorization", "Bearer #{credential}")
      |> post(~p"/api/v1/forum/topics", %{
        forum: forum.slug,
        title: "Agent topic",
        body_text: "Created without a human link"
      })

    assert %{"topic" => topic, "posts" => [%{"author" => author}]} =
             json_response(topic_conn, 201)

    assert author["is_agent"] == true
    assert author["ref"] == "agent:#{agent.id}"
    assert author["display_name"] == agent.display_name

    topic_id = topic["id"]

    reply_conn =
      conn
      |> put_req_header("authorization", "Bearer #{credential}")
      |> post(~p"/api/v1/forum/topics/#{topic_id}/posts", %{
        body_text: "Agent reply"
      })

    assert %{"post" => %{"author" => %{"is_agent" => true, "ref" => "agent:" <> _}}} =
             json_response(reply_conn, 201)
  end

  test "POST /api/v1/forum/topics/:id/posts replies to a topic", %{conn: conn, forum: forum} do
    topic = topic(forum)

    conn =
      conn
      |> put_forge_api_token("forum-api-reply")
      |> post(~p"/api/v1/forum/topics/#{topic.id}/posts", %{body_text: "API reply"})

    assert %{"post" => post} = json_response(conn, 201)
    assert post["post_number"] == 2
    assert Forum.count_posts(topic) == 2
  end

  test "a suspended agent is refused on the forum reply route", %{conn: conn, forum: forum} do
    {:ok, topic} =
      Forum.create_topic(forum, %{
        title: "Suspended target",
        slug: "suspended-target",
        body_text: "Existing topic",
        idempotency_key: Ecto.UUID.generate(),
        actor_ref: "agent:suspended",
        actor_display_name: "Suspended",
        actor_slug: "suspended"
      })

    {:ok, agent, credential} =
      Agents.register(%{
        handle: "suspended-forum-bot",
        display_name: "Suspended forum bot",
        registration_ip: "192.0.2.41"
      })

    assert {:ok, _suspended} = Agents.suspend(agent, "test")

    conn =
      conn
      |> put_req_header("authorization", "Bearer #{credential}")
      |> post(~p"/api/v1/forum/topics/#{topic.id}/posts", %{body_text: "Should fail"})

    assert conn.status == 401
  end

  test "linking and unlinking do not rewrite forum authorship", %{conn: conn, forum: forum} do
    {:ok, agent, credential} =
      Agents.register(%{
        handle: "stable-forum-bot",
        display_name: "Stable forum bot",
        registration_ip: "192.0.2.42"
      })

    {:ok, user} =
      Accounts.upsert_github_user(%{
        github_id: 992_042,
        github_login: "stable-forum-reviewer",
        github_avatar_url: "https://avatars.githubusercontent.com/u/992042?v=4"
      })

    created =
      conn
      |> put_req_header("authorization", "Bearer #{credential}")
      |> post(~p"/api/v1/forum/topics", %{
        forum: forum.slug,
        title: "Stable topic",
        body_text: "Authorship must remain stable"
      })

    assert %{"topic" => %{"id" => topic_id}} = json_response(created, 201)
    before = get(conn, ~p"/api/v1/forum/topics/#{topic_id}") |> json_response(200)

    assert {:ok, pending} = Agents.request_link(agent, user)
    assert {:ok, _linked} = Agents.accept_link(user, pending.id)
    assert {:ok, _unlinked} = Agents.unlink(agent, user)

    after_link = get(conn, ~p"/api/v1/forum/topics/#{topic_id}") |> json_response(200)
    assert before["topic"]["actor_ref"] == after_link["topic"]["actor_ref"]
    assert before["topic"]["actor_display_name"] == after_link["topic"]["actor_display_name"]
    assert before["topic"]["actor_slug"] == after_link["topic"]["actor_slug"]
    assert before["posts"] == after_link["posts"]

    profile = get(conn, ~p"/api/v1/agents/#{agent.handle}") |> json_response(200)
    refute Map.has_key?(profile["agent"], "owner")
  end

  test "POST /api/v1/forum/claims starts an identity claim", %{conn: conn} do
    conn =
      conn
      |> put_forge_api_token("forum-api-claim")
      |> post(~p"/api/v1/forum/claims", %{
        actor_ref: "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20"
      })

    assert %{"claim" => %{"status" => "pending"}} = json_response(conn, 201)
  end

  test "GET /api/v1/forum/claims lists the caller's claims", %{conn: conn} do
    authed = put_forge_api_token(conn, "forum-api-list-claims")

    {:ok, _} =
      Forum.start_actor_link(github_user("api-token-forum-api-list-claims"), "agent:user_1")

    _ = authed

    conn = get(authed, ~p"/api/v1/forum/claims")

    assert %{"claims" => claims} = json_response(conn, 200)
    assert length(claims) >= 1
  end

  test "unknown board is 404", %{conn: conn} do
    conn = get(conn, ~p"/api/v1/forum/topics?forum=nope")

    assert json_response(conn, 404)
  end

  describe "search" do
    test "matches a topic title across every readable board", %{conn: conn, forum: forum} do
      topic(forum, %{title: "Router latency", slug: "router-latency"})
      topic(forum, %{title: "Something else", slug: "something-else"})

      conn = get(conn, ~p"/api/v1/forum/topics?q=latency")

      assert %{"query" => "latency", "topics" => [t], "board" => nil} = json_response(conn, 200)
      assert t["title"] == "Router latency"
      assert t["board"]["slug"] == "general"
    end

    test "matches a visible post body", %{conn: conn, forum: forum} do
      topic(forum, %{title: "Deploy notes", slug: "deploy-notes", body_text: "watch the walrus"})

      conn = get(conn, ~p"/api/v1/forum/topics?q=walrus")

      assert %{"topics" => [t]} = json_response(conn, 200)
      assert t["title"] == "Deploy notes"
    end

    test "matches a topic author's display name and slug", %{conn: conn, forum: forum} do
      topic(forum, %{
        title: "Release notes",
        slug: "release-notes",
        actor_display_name: "Fable Coder",
        actor_slug: "fable-coder"
      })

      topic(forum, %{title: "Unrelated", slug: "unrelated"})

      by_name = get(conn, ~p"/api/v1/forum/topics?q=fable")
      assert %{"topics" => [t]} = json_response(by_name, 200)
      assert t["title"] == "Release notes"

      by_slug = get(conn, ~p"/api/v1/forum/topics?q=fable-coder")
      assert %{"topics" => [_]} = json_response(by_slug, 200)
    end

    test "matches a visible reply's author", %{conn: conn, forum: forum} do
      topic = topic(forum, %{title: "Quiet thread", slug: "quiet-thread"})

      {:ok, _post} =
        Forum.create_post(topic, %{
          body_text: "checking in",
          actor_ref: "agent:fable-reply-bot",
          actor_display_name: "Fable reply bot",
          actor_slug: "fable-reply-bot",
          idempotency_key: Ecto.UUID.generate()
        })

      conn = get(conn, ~p"/api/v1/forum/topics?q=fable")

      assert %{"topics" => [t]} = json_response(conn, 200)
      assert t["title"] == "Quiet thread"
    end

    test "stays inside one board when given a board", %{conn: conn, forum: forum} do
      {:ok, other} =
        %Forum.Forum{}
        |> Forum.Forum.changeset(%{slug: "meta", title: "Meta"})
        |> Repo.insert()

      topic(forum, %{title: "Shared word here", slug: "shared-general"})
      topic(other, %{title: "Shared word there", slug: "shared-meta"})

      conn = get(conn, ~p"/api/v1/forum/topics?forum=meta&q=shared")

      assert %{"topics" => [t], "board" => board} = json_response(conn, 200)
      assert board["slug"] == "meta"
      assert t["title"] == "Shared word there"
    end

    test "never returns a private board's topics to an anonymous caller", %{conn: conn} do
      private = private_forum()
      topic(private, %{title: "Private plan", slug: "private-plan"})

      conn = get(conn, ~p"/api/v1/forum/topics?q=private")

      assert %{"topics" => []} = json_response(conn, 200)
    end

    test "returns a private board's topics to an operator", %{conn: conn} do
      private = private_forum()
      topic(private, %{title: "Private plan", slug: "private-plan"})

      conn =
        conn
        |> operator_token("forum-api-search-operator")
        |> get(~p"/api/v1/forum/topics?q=private")

      assert %{"topics" => [t]} = json_response(conn, 200)
      assert t["title"] == "Private plan"
    end
  end

  describe "authorization of reads" do
    test "GET /api/v1/forum omits a private board", %{conn: conn} do
      private_forum()

      conn = get(conn, ~p"/api/v1/forum")

      assert %{"boards" => boards} = json_response(conn, 200)
      assert Enum.map(boards, & &1["slug"]) == ["general"]
    end

    test "GET /api/v1/forum lists a private board for an operator", %{conn: conn} do
      private_forum()

      conn = conn |> operator_token("forum-api-boards-operator") |> get(~p"/api/v1/forum")

      assert %{"boards" => boards} = json_response(conn, 200)
      assert "private" in Enum.map(boards, & &1["slug"])
    end

    test "a private board's topic list is 404 for an anonymous caller", %{conn: conn} do
      private_forum()

      conn = get(conn, ~p"/api/v1/forum/topics?forum=private")

      assert json_response(conn, 404)
    end

    test "a private board's thread is 404 for an anonymous caller", %{conn: conn} do
      topic = topic(private_forum(), %{title: "Private plan", slug: "private-plan"})

      conn = get(conn, ~p"/api/v1/forum/topics/#{topic.id}")

      assert json_response(conn, 404)
    end

    test "a hidden post never reaches the thread", %{conn: conn, forum: forum} do
      topic = topic(forum)
      [first] = Forum.list_posts(topic)
      {:ok, _hidden} = Forum.hide_post(first)

      conn = get(conn, ~p"/api/v1/forum/topics/#{topic.id}")

      assert %{"posts" => []} = json_response(conn, 200)
    end

    test "a malformed topic identifier is 404", %{conn: conn} do
      conn = get(conn, ~p"/api/v1/forum/topics/not-a-uuid")

      assert json_response(conn, 404)
    end
  end

  describe "moderation" do
    test "PATCH /api/v1/forum/topics/:id closes and pins a topic", %{conn: conn, forum: forum} do
      topic = topic(forum)

      conn =
        conn
        |> operator_token("forum-api-close")
        |> patch(~p"/api/v1/forum/topics/#{topic.id}", %{state: "closed", pinned: true})

      assert %{"topic" => t} = json_response(conn, 200)
      assert t["state"] == "closed"
      assert t["pinned"] == true
    end

    test "PATCH /api/v1/forum/topics/:id refuses a non-operator", %{conn: conn, forum: forum} do
      topic = topic(forum)

      conn =
        conn
        |> put_forge_api_token("forum-api-close-denied")
        |> patch(~p"/api/v1/forum/topics/#{topic.id}", %{state: "closed"})

      assert json_response(conn, 403)
    end

    test "PATCH /api/v1/forum/topics/:id rejects an unknown state", %{conn: conn, forum: forum} do
      topic = topic(forum)

      conn =
        conn
        |> operator_token("forum-api-bad-state")
        |> patch(~p"/api/v1/forum/topics/#{topic.id}", %{state: "melted"})

      assert %{"errors" => %{"state" => [_message]}} = json_response(conn, 422)
    end

    test "PATCH /api/v1/forum/posts/:id hides a post", %{conn: conn, forum: forum} do
      topic = topic(forum)
      [first] = Forum.list_posts(topic)

      conn =
        conn
        |> operator_token("forum-api-hide")
        |> patch(~p"/api/v1/forum/posts/#{first.id}", %{state: "hidden"})

      assert %{"post" => %{"state" => "hidden"}} = json_response(conn, 200)
      assert Forum.list_posts(topic) == []
    end

    test "PATCH /api/v1/forum/posts/:id refuses a non-operator", %{conn: conn, forum: forum} do
      topic = topic(forum)
      [first] = Forum.list_posts(topic)

      conn =
        conn
        |> put_forge_api_token("forum-api-hide-denied")
        |> patch(~p"/api/v1/forum/posts/#{first.id}", %{state: "hidden"})

      assert json_response(conn, 403)
      assert [_post] = Forum.list_posts(topic)
    end
  end

  describe "claim review" do
    test "GET /api/v1/forum/claims/pending lists every pending claim", %{conn: conn} do
      {:ok, _claim} =
        Forum.start_actor_link(github_user("forum-api-pending-claimant"), "agent:user_2")

      conn = conn |> operator_token("forum-api-pending") |> get(~p"/api/v1/forum/claims/pending")

      assert %{"claims" => [%{"actor_ref" => "agent:user_2"}]} = json_response(conn, 200)
    end

    test "GET /api/v1/forum/claims/pending refuses a non-operator", %{conn: conn} do
      conn =
        conn
        |> put_forge_api_token("forum-api-pending-denied")
        |> get(~p"/api/v1/forum/claims/pending")

      assert json_response(conn, 403)
    end

    test "PATCH /api/v1/forum/claims/:id links a claim", %{conn: conn} do
      {:ok, claim} =
        Forum.start_actor_link(github_user("forum-api-review-claimant"), "agent:user_3")

      conn =
        conn
        |> operator_token("forum-api-review")
        |> patch(~p"/api/v1/forum/claims/#{claim.id}", %{status: "linked"})

      assert %{"claim" => %{"status" => "linked"}} = json_response(conn, 200)
    end

    test "PATCH /api/v1/forum/claims/:id is a conflict once a claim is settled", %{conn: conn} do
      {:ok, claim} =
        Forum.start_actor_link(github_user("forum-api-settled-claimant"), "agent:user_4")

      {:ok, claim} = Forum.reject_actor_link(claim)

      conn =
        conn
        |> operator_token("forum-api-settled")
        |> patch(~p"/api/v1/forum/claims/#{claim.id}", %{status: "linked"})

      assert %{"error" => "claim_not_pending"} = json_response(conn, 409)
    end
  end

  defp private_forum do
    {:ok, forum} =
      %Forum.Forum{}
      |> Forum.Forum.changeset(%{
        slug: "private",
        title: "Private",
        visibility: "private",
        discoverability: "unlisted"
      })
      |> Repo.insert()

    forum
  end

  defp operator_token(conn, key) do
    grant_operator(github_user("api-token-" <> key))
    put_forge_api_token(conn, key)
  end
end