test/openagents/threads/visibility_test.exs

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Threads.VisibilityTest do
  @moduledoc """
  THREAD-002: a thread's transcript is private until its owner says otherwise.

  The tier vocabulary is not this module's invention — it is the
  `dark/pulse/ledger/glass` ladder `OpenAgents.Transparency` and
  `OpenAgents.Forge.Visibility` already use (`docs/taxonomy.md`), and the first
  test here holds the thread's admitted set to it, so a fifth word cannot enter
  through this door.
  """
  use OpenAgents.DataCase, async: false

  import OpenAgentsWeb.ConnCase, only: [github_user: 1]

  alias OpenAgents.DataRights.AccountExport
  alias OpenAgents.Threads
  alias OpenAgents.Threads.Thread
  alias OpenAgents.Transparency

  import OpenAgents.IssuesFixtures

  defp owner(key), do: github_user("thread-visibility-#{key}")

  defp event_types(thread) do
    thread |> Threads.list_events() |> Enum.map(& &1.event_type)
  end

  describe "the vocabulary" do
    test "a thread's tiers are rungs of the shared transparency ladder" do
      shared = Enum.map(Transparency.tier_atoms(), &Atom.to_string/1)

      assert Thread.visibilities() -- shared == [],
             "a thread must not invent a tier word the transparency ladder does not have"

      assert Thread.default_visibility() == "dark"
      assert Thread.wide_visibilities() == ["ledger"]
    end

    test "the tiers with no thread read path behind them are not offered" do
      refute "pulse" in Thread.visibilities()
      refute "glass" in Thread.visibilities()
    end
  end

  describe "open/3" do
    test "a thread defaults to owner-only" do
      {:ok, thread} = Threads.open(owner("default"), "Keep it to myself")

      assert thread.visibility == "dark"
      refute Thread.wide?(thread)
    end

    test "an explicit tier is accepted and recorded as an act" do
      {:ok, thread} =
        Threads.open(owner("explicit"), "Share the transcript", visibility: "ledger")

      assert thread.visibility == "ledger"
      assert Thread.wide?(thread)

      # Widening leaves a record in the transcript, not only a column value.
      assert event_types(thread) == ["thread.opened", "thread.visibility_set"]
      assert thread.event_count == 2

      widened = thread |> Threads.list_events() |> List.last()
      assert widened.payload == %{"visibility" => "ledger", "from" => "dark"}
    end

    test "a default thread records no widening" do
      {:ok, thread} = Threads.open(owner("unwidened"), "Nothing to declare")

      assert event_types(thread) == ["thread.opened"]
      assert thread.event_count == 1
    end

    test "an unknown tier is refused, and nothing is opened" do
      user = owner("unknown")

      assert {:error, changeset} = Threads.open(user, "Widen me", visibility: "public")
      assert "is invalid" in errors_on(changeset).visibility

      assert Threads.list_for_user(user) == []
    end

    test "a tier the surface cannot enforce is refused like any other" do
      for tier <- ~w(pulse glass) do
        assert {:error, changeset} =
                 Threads.open(owner("unenforceable"), "Widen me", visibility: tier)

        assert "is invalid" in errors_on(changeset).visibility
      end
    end
  end

  describe "fetch_readable/2" do
    test "the owner reads their own thread at either tier" do
      user = owner("owner-reads")

      {:ok, dark} = Threads.open(user, "Private work")
      {:ok, wide} = Threads.open(user, "Shared work", visibility: "ledger")

      assert {:ok, %Thread{id: id}, :owner} = Threads.fetch_readable(user, dark.id)
      assert id == dark.id
      assert {:ok, %Thread{}, :owner} = Threads.fetch_readable(user, wide.id)
    end

    test "a stranger reads a wide thread and is named a reader, not an owner" do
      {:ok, thread} = Threads.open(owner("wide"), "Shared work", visibility: "ledger")

      assert {:ok, %Thread{id: id}, :reader} =
               Threads.fetch_readable(owner("stranger-wide"), thread.id)

      assert id == thread.id
    end

    test "a stranger cannot read an owner-only thread" do
      {:ok, thread} = Threads.open(owner("dark"), "Private work")

      assert Threads.fetch_readable(owner("stranger-dark"), thread.id) == :error
    end

    test "an unknown id is the same refusal as somebody else's private thread" do
      assert Threads.fetch_readable(owner("missing"), Ecto.UUID.generate()) == :error
      assert Threads.fetch_readable(owner("missing"), "not-a-uuid") == :error
    end

    test "writing stays owner-only however wide the tier" do
      {:ok, thread} = Threads.open(owner("write-fence"), "Shared work", visibility: "ledger")
      stranger = owner("stranger-write")

      # The read admits the stranger; the owner-scoped lookup every write and
      # every mint resolves through does not.
      assert {:ok, _thread, :reader} = Threads.fetch_readable(stranger, thread.id)
      assert Threads.get_for_user(stranger, thread.id) == nil
    end
  end

  describe "the account export" do
    test "carries the account's threads, their events, and the tier that governs them" do
      user = owner("export")

      {:ok, thread} =
        Threads.open(user, "Export this transcript",
          repository: "OpenAgentsInc/openagents.com",
          visibility: "ledger"
        )

      {:ok, _thread} =
        Threads.record_event(thread, "tool.ran", %{"tool" => "bash", "status" => "ok"})

      assert {:ok, export} = AccountExport.build(user)
      assert %{"records" => [record]} = export["threads"]

      assert record["id"] == thread.id
      assert record["objective"] == "Export this transcript"
      assert record["repository"] == "OpenAgentsInc/openagents.com"
      # The consent record travels with the data it governs.
      assert record["visibility"] == "ledger"

      types = Enum.map(record["events"], & &1["event_type"])
      assert "thread.opened" in types
      assert "thread.visibility_set" in types
      assert "tool.ran" in types

      # A plugin run is a thread event, so the export reaches it through the
      # thread rather than through a second collection (#206).
      tool_ran = Enum.find(record["events"], &(&1["event_type"] == "tool.ran"))
      assert tool_ran["payload"] == %{"tool" => "bash", "status" => "ok"}
    end

    test "an owner-only thread exports with its default tier stated" do
      user = owner("export-dark")
      {:ok, _thread} = Threads.open(user, "Private work")

      assert {:ok, export} = AccountExport.build(user)
      assert [%{"visibility" => "dark"}] = export["threads"]["records"]
    end
  end

  describe "issue references" do
    test "a thread can name an issue and remain unnamed" do
      user = owner("issue-link")
      repository = repository_fixture()
      issue = issue_fixture(repository, title: "Linked issue")

      assert {:ok, named} =
               Threads.open(user, "Work for the issue", issue_id: issue.id)

      assert named.issue_id == issue.id

      assert {:ok, unnamed} = Threads.open(user, "Work with no issue")
      assert is_nil(unnamed.issue_id)
    end

    test "list_for_issue returns only threads the reader may read" do
      repository = repository_fixture()
      issue = issue_fixture(repository, title: "Issue with threads")
      owner = owner("issue-owner")
      stranger = owner("issue-stranger")

      {:ok, dark} =
        Threads.open(owner, "Owner-only thread", issue_id: issue.id)

      {:ok, ledger} =
        Threads.open(owner, "Ledger thread",
          issue_id: issue.id,
          visibility: "ledger"
        )

      owner_threads = Threads.list_for_issue(issue, owner) |> Enum.map(& &1.id)
      assert dark.id in owner_threads
      assert ledger.id in owner_threads

      stranger_threads =
        Threads.list_for_issue(issue, stranger) |> Enum.map(& &1.id)

      refute dark.id in stranger_threads
      assert ledger.id in stranger_threads
    end
  end
end