test/openagents_web/thread_visibility_test.exs

58e6347eeb72 · 7 KB

defmodule OpenAgentsWeb.ThreadVisibilityTest do
  @moduledoc """
  THREAD-002 at the two doors a thread is read through: the API and the web
  transcript viewer.

  Both doors answer the same three questions the same way — an owner reads
  their own thread, a reader admitted by the thread's tier reads it without the
  owner's budget, and a stranger at an owner-only thread gets the plain 404
  that never confirms the thread exists.
  """
  use OpenAgentsWeb.ConnCase, async: false

  import Phoenix.LiveViewTest

  alias OpenAgents.Threads

  defp signed_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})

  describe "POST /api/v1/threads" do
    test "a thread opens owner-only when the caller names no tier", %{conn: conn} do
      thread =
        conn
        |> put_chat_api_token("visibility-default")
        |> post(~p"/api/v1/threads", %{"objective" => "Keep it to myself."})
        |> json_response(201)
        |> Map.fetch!("thread")

      assert thread["visibility"] == "dark"
    end

    test "an explicit tier is accepted and published", %{conn: conn} do
      thread =
        conn
        |> put_chat_api_token("visibility-explicit")
        |> post(~p"/api/v1/threads", %{
          "objective" => "Share the transcript.",
          "visibility" => "ledger"
        })
        |> json_response(201)
        |> Map.fetch!("thread")

      assert thread["visibility"] == "ledger"
      assert thread["event_count"] == 2
    end

    test "an unknown tier is refused with its own code, not a bare 422", %{conn: conn} do
      body =
        conn
        |> put_chat_api_token("visibility-unknown")
        |> post(~p"/api/v1/threads", %{
          "objective" => "Widen me.",
          "visibility" => "public"
        })
        |> assert_api_error(422, nil)

      assert body["code"] == "thread_visibility_unsupported"
      assert body["message"] =~ "\"public\" is not an admitted thread visibility"
      assert body["message"] =~ "Admitted: dark, ledger"
      assert body["errors"]["visibility"] != nil
    end

    test "a tier this surface cannot enforce is refused the same way", %{conn: conn} do
      for tier <- ~w(pulse glass) do
        body =
          conn
          |> put_chat_api_token("visibility-unenforceable-" <> tier)
          |> post(~p"/api/v1/threads", %{"objective" => "Widen me.", "visibility" => tier})
          |> assert_api_error(422, nil)

        assert body["code"] == "thread_visibility_unsupported"
      end
    end

    test "a non-string tier is refused as a field error", %{conn: conn} do
      body =
        conn
        |> put_chat_api_token("visibility-nonstring")
        |> post(~p"/api/v1/threads", %{"objective" => "Widen me.", "visibility" => 3})
        |> assert_api_error(422, nil)

      assert body["code"] == "validation_failed"
      assert body["errors"]["visibility"] != nil
    end
  end

  describe "GET /api/v1/threads/{thread_id}" do
    test "a reader admitted by the tier reads the thread without the owner's grant", %{conn: conn} do
      {:ok, thread} =
        Threads.open(github_user("api-wide-owner"), "Shared work", visibility: "ledger")

      body =
        conn
        |> put_chat_api_token("api-wide-reader")
        |> get(~p"/api/v1/threads/#{thread.id}")
        |> json_response(200)

      assert body["thread"]["id"] == thread.id
      assert body["thread"]["visibility"] == "ledger"
      # The tier discloses the transcript. It does not disclose what the
      # owner's account is spending.
      assert body["grant"] == nil
    end

    test "the owner still reads their own grant", %{conn: conn} do
      conn = put_chat_api_token(conn, "api-owner-grant")

      thread =
        conn
        |> post(~p"/api/v1/threads", %{
          "objective" => "Shared work.",
          "visibility" => "ledger"
        })
        |> json_response(201)
        |> Map.fetch!("thread")

      body = conn |> get(~p"/api/v1/threads/#{thread["id"]}") |> json_response(200)

      assert body["grant"]["status"] == "active"
    end

    test "a stranger at an owner-only thread gets the plain 404", %{conn: conn} do
      {:ok, thread} = Threads.open(github_user("api-dark-owner"), "Private work")

      assert conn
             |> put_chat_api_token("api-dark-stranger")
             |> get(~p"/api/v1/threads/#{thread.id}")
             |> api_error_code(404) == "not_found"
    end

    test "the transcript follows the same tier as the thread", %{conn: conn} do
      owner = github_user("api-events-owner")
      {:ok, wide} = Threads.open(owner, "Shared work", visibility: "ledger")
      {:ok, _wide} = Threads.record_event(wide, "tool.ran", %{"tool" => "bash"})
      {:ok, dark} = Threads.open(owner, "Private work")

      reader = put_chat_api_token(conn, "api-events-reader")

      body = reader |> get(~p"/api/v1/threads/#{wide.id}/events") |> json_response(200)
      assert Enum.any?(body["events"], &(&1["event_type"] == "tool.ran"))

      assert reader |> get(~p"/api/v1/threads/#{dark.id}/events") |> api_error_code(404) ==
               "not_found"
    end

    test "a wider tier widens reads only: a reader cannot write, cancel, or re-mint", %{
      conn: conn
    } do
      {:ok, thread} =
        Threads.open(github_user("api-fence-owner"), "Shared work", visibility: "ledger")

      reader = put_chat_api_token(conn, "api-fence-reader")

      assert reader
             |> post(~p"/api/v1/threads/#{thread.id}/events", %{
               "event_type" => "turn.user",
               "payload" => %{"text" => "not yours"}
             })
             |> api_error_code(404) == "not_found"

      assert reader
             |> post(~p"/api/v1/threads/#{thread.id}/grants", %{})
             |> api_error_code(404) == "not_found"

      assert reader |> delete(~p"/api/v1/threads/#{thread.id}") |> api_error_code(404) ==
               "not_found"

      assert Threads.get_for_user(github_user("api-fence-owner"), thread.id).status == "open"
    end
  end

  describe "/threads/:id" do
    test "the owner sees the tier and their own budget", %{conn: conn} do
      owner = github_user("live-owner")
      {:ok, thread} = Threads.open(owner, "Private work")
      {:ok, _thread, _grant, _token} = Threads.mint_grant(thread)

      {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads/#{thread.id}")

      assert view |> element("#thread-visibility") |> render() =~ "dark"
      assert has_element?(view, "#thread-budget")
    end

    test "a reader admitted by the tier reads the transcript without the budget", %{conn: conn} do
      owner = github_user("live-wide-owner")
      {:ok, thread} = Threads.open(owner, "Shared work", visibility: "ledger")
      {:ok, thread} = Threads.record_event(thread, "turn.user", %{"text" => "Fix the parser"})
      {:ok, _thread, _grant, _token} = Threads.mint_grant(thread)

      reader = github_user("live-wide-reader")
      {:ok, view, _html} = live(signed_in(conn, reader), ~p"/threads/#{thread.id}")

      assert render(view) =~ "Fix the parser"
      assert view |> element("#thread-visibility") |> render() =~ "ledger"
      refute has_element?(view, "#thread-budget")
    end

    test "a stranger at an owner-only thread gets the same 404 as a missing one", %{conn: conn} do
      {:ok, thread} = Threads.open(github_user("live-dark-owner"), "Private work")
      stranger = signed_in(conn, github_user("live-dark-stranger"))

      assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
        live(stranger, ~p"/threads/#{thread.id}")
      end

      assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
        live(stranger, ~p"/threads/#{Ecto.UUID.generate()}")
      end
    end
  end
end