test/openagents/threads/grant_token_reach_test.exs

main at 58e6347eeb72 · 10 KB

defmodule OpenAgents.Threads.GrantTokenReachTest do
  @moduledoc """
  The executable enumeration behind THREAD-001's token clause.

  THREAD-001 says no route returns a grant token for a thread the caller did
  not open, and the token is returned exactly once, at the mint.
  `OpenAgentsWeb.ThreadControllerTest` proves both at the three routes that
  exist, which is a proof of those routes rather than of the sentence: a second
  route that renders a grant would pass every test in this repository.

  So the population is enumerated instead of sampled. A plaintext grant token
  comes into existence in exactly one place, `OpenAgents.Inference.mint/1`, and
  leaves `OpenAgents.Threads` through exactly the exports named below. Every
  module that can hold one therefore carries a compiled import edge to one of
  those functions, and the edges are read from each module's BEAM import table
  rather than from source text, so a comment cannot add a caller and a rename
  cannot hide one.

  Four sets close it:

  1. the modules that mint a token,
  2. the modules that receive one from a thread,
  3. the routed handlers in either set, which must be the one controller that
     serves the mint,
  4. `OpenAgents.Threads`'s own export table, so a new function that hands a
     caller a token is classified here before anything can call it.

  The fifth test dispatches every route the router gives that controller and
  requires the body to carry a token only at the mint. It derives the route set
  from `OpenAgentsWeb.Router.__routes__/0`, so a fourth thread route is
  dispatched by this test the day it lands.
  """

  use OpenAgentsWeb.ConnCase, async: false

  alias OpenAgents.Threads

  # Every module whose import table names `OpenAgents.Inference.mint/1`, with
  # what it does with the plaintext token the mint returns.
  @token_minters %{
    OpenAgents.Threads => "returns it to the caller of mint_grant/1 and open_and_mint/3",
    OpenAgents.Work.Coding => "discards it; the meter is internal and nothing redeems it",
    OpenAgents.Work.DelegationServer => "injects it into the probe process it spawns",
    OpenAgents.Work.Scv => "discards it; the meter is internal and nothing redeems it"
  }

  # `OpenAgents.Threads`'s exports, each classified by what it can hand a
  # caller. A new export fails this until it is named here, which is where
  # someone has to answer whether it returns a token or resolves a thread.
  @threads_api %{
    {:active_grants, 1} => :thread_struct,
    {:cancel, 1} => :thread_struct,
    {:cancel, 2} => :thread_struct,
    {:ceilings, 0} => :no_thread,
    {:ceilings, 1} => :no_thread,
    {:default_permission_profile, 0} => :no_thread,
    {:default_reasoning, 0} => :no_thread,
    {:fetch_readable, 2} => :scoped_by_tier,
    {:finish, 2} => :thread_struct,
    {:get_for_user, 2} => :scoped_by_owner,
    {:latest_grant, 1} => :thread_struct,
    {:list_events, 1} => :thread_struct,
    {:list_events, 2} => :thread_struct,
    {:list_for_issue, 2} => :list_by_issue,
    {:list_for_user, 1} => :scoped_by_owner,
    {:list_for_user, 2} => :scoped_by_owner,
    {:maximum_event_batch, 0} => :no_thread,
    {:maximum_open_per_account, 0} => :no_thread,
    {:mint_grant, 1} => :returns_plaintext_token,
    {:open, 2} => :scoped_by_owner,
    {:open, 3} => :scoped_by_owner,
    {:open_and_mint, 2} => :returns_plaintext_token,
    {:open_and_mint, 3} => :returns_plaintext_token,
    {:open_count, 1} => :scoped_by_owner,
    # Narrows a query rather than resolving anything. It is the tier predicate
    # `fetch_readable/2` and `list_for_issue/2` both compose, exported so a
    # caller outside this context — `OpenAgents.Issues.progress_map/2` — reads
    # threads through the same rule instead of writing a second one. It takes
    # no identifier and returns no thread, so it hands a caller nothing it did
    # not already have authority to ask for.
    {:readable_by, 2} => :tier_predicate,
    {:reap_expired, 1} => :scoped_by_owner,
    {:record_event, 3} => :thread_struct,
    {:record_events, 2} => :thread_struct,
    {:spend, 1} => :thread_struct,
    {:subscribe, 1} => :thread_struct,
    {:unsubscribe, 1} => :thread_struct,
    {:subscribe_user, 1} => :scoped_by_owner,
    {:unsubscribe_user, 1} => :scoped_by_owner
  }

  # Every module that reaches a token-returning `OpenAgents.Threads` export.
  # One controller, serving one route: the mint.
  @grant_token_holders [OpenAgentsWeb.ThreadController]

  # The two functions that resolve a thread from an identifier, and every
  # module that calls each. Both take the acting account, so neither can be
  # handed a bare id (THREAD-001, IDENTITY-002), and they differ in exactly one
  # way: `get_for_user/2` admits the owner and nobody else, while
  # `fetch_readable/2` also admits a reader the thread's transparency tier
  # names (THREAD-002).
  #
  # The split is the enforcement. Every verb that writes to a thread or mints
  # its authority resolves through the owner-scoped lookup, so widening a
  # transcript for reading can never widen what may be done to it. The one
  # controller serves both, because it serves both the reads and the writes;
  # the web viewer only reads. The Gym resolves owner-scoped to verify a
  # trial's claimed thread belongs to the bearer before linking it (#241);
  # it never writes to the thread or mints for it.
  @owner_resolver_callers [OpenAgents.Gym, OpenAgentsWeb.ThreadController]
  @tier_resolver_callers [OpenAgentsWeb.ThreadController, OpenAgentsWeb.ThreadShowLive]

  test "the modules that mint a grant token are exactly the set THREAD-001 accounts for" do
    assert_exact_set(
      callers_of([{OpenAgents.Inference, :mint, 1}]),
      Map.keys(@token_minters),
      "mints a grant token"
    )
  end

  test "the exports of OpenAgents.Threads are exactly the ones classified here" do
    actual = Threads.__info__(:functions) |> Enum.reject(fn {name, _} -> name == :__struct__ end)

    assert_exact_set(
      actual,
      Map.keys(@threads_api),
      "is an OpenAgents.Threads export; classify whether it returns a plaintext token"
    )
  end

  test "the modules that receive a thread's grant token are exactly the ones named" do
    assert token_returning() != []

    assert_exact_set(
      callers_of(token_returning()),
      @grant_token_holders,
      "holds a plaintext grant token"
    )
  end

  test "the only routed handler that can hold a grant token serves the mint" do
    holders = MapSet.new(callers_of([{OpenAgents.Inference, :mint, 1} | token_returning()]))

    routed =
      OpenAgentsWeb.Router.__routes__()
      |> MapSet.new(& &1.plug)
      |> MapSet.intersection(holders)

    assert MapSet.to_list(routed) == [OpenAgentsWeb.ThreadController],
           """
           A routed handler other than the thread mint can hold a plaintext
           grant token. THREAD-001 says no other route returns one.
           """
  end

  test "a thread resolves by identifier only through the two account-taking lookups" do
    owner_scoped = for {key, :scoped_by_owner} <- @threads_api, do: key
    tier_scoped = for {key, :scoped_by_tier} <- @threads_api, do: key

    assert {:get_for_user, 2} in owner_scoped
    assert tier_scoped == [{:fetch_readable, 2}]

    assert_exact_set(
      callers_of([{OpenAgents.Threads, :get_for_user, 2}]),
      @owner_resolver_callers,
      "resolves a thread by identifier, owner-scoped"
    )

    assert_exact_set(
      callers_of([{OpenAgents.Threads, :fetch_readable, 2}]),
      @tier_resolver_callers,
      "resolves a thread by identifier through its transparency tier"
    )
  end

  test "every route the router gives the thread controller returns a token only at the mint" do
    conn = put_chat_api_token(build_conn(), "grant-token-reach")

    created =
      conn
      |> post(~p"/api/v1/threads", %{"objective" => "Count the doors that hand out authority."})
      |> json_response(201)

    thread_id = created["thread"]["id"]
    assert is_binary(created["grant"]["token"])

    routes =
      Enum.filter(OpenAgentsWeb.Router.__routes__(), &(&1.plug == OpenAgentsWeb.ThreadController))

    assert length(routes) >= 3

    for route <- routes, route.plug_opts != :create do
      path = String.replace(route.path, ":thread_id", thread_id)

      # Whatever it answers. A route that refuses this bare call — `record`
      # requires an event type — is still a route that must not put a token in
      # its body, and the invariant is about what reaches the caller rather
      # than about which status it reaches them with.
      answered = dispatch_route(conn, route.verb, path)
      body = Jason.decode!(answered.resp_body)

      refute token?(body),
             """
             #{route.verb |> to_string() |> String.upcase()} #{route.path} returned a grant
             token. THREAD-001 says the token is returned exactly once, at the mint.
             """
    end
  end

  # The `OpenAgents.Threads` exports that hand a caller the plaintext token,
  # derived from the classification above rather than listed twice.
  defp token_returning do
    for {{name, arity}, :returns_plaintext_token} <- @threads_api,
        do: {OpenAgents.Threads, name, arity}
  end

  defp dispatch_route(conn, :get, path), do: get(conn, path)
  defp dispatch_route(conn, :delete, path), do: delete(conn, path)
  defp dispatch_route(conn, :post, path), do: post(conn, path, %{})

  # A token is a value, not a key: a response that renamed the field would
  # still be caught, because the plaintext carries the mint's prefix.
  defp token?(value) when is_binary(value), do: String.starts_with?(value, "sig_")
  defp token?(value) when is_map(value), do: value |> Map.values() |> Enum.any?(&token?/1)
  defp token?(value) when is_list(value), do: Enum.any?(value, &token?/1)
  defp token?(_value), do: false

  # Read from each compiled module's import table rather than from source text.
  defp callers_of(mfas) do
    wanted = MapSet.new(mfas)
    {:ok, modules} = :application.get_key(:openagents, :modules)

    Enum.filter(modules, fn module ->
      with path when is_list(path) <- :code.which(module),
           {:ok, {^module, [imports: imports]}} <- :beam_lib.chunks(path, [:imports]) do
        Enum.any?(imports, &MapSet.member?(wanted, &1))
      else
        _unreadable -> false
      end
    end)
  end

  defp assert_exact_set(actual, declared, what) do
    actual = MapSet.new(actual)
    declared = MapSet.new(declared)

    assert MapSet.difference(actual, declared) |> MapSet.to_list() == [],
           """
           Something that #{what} is not named in
           test/openagents/threads/grant_token_reach_test.exs. Amend THREAD-001
           in INVARIANTS.md, then add it here.
           """

    assert MapSet.difference(declared, actual) |> MapSet.to_list() == [],
           """
           This test names something that no longer #{what}. Amend THREAD-001
           in INVARIANTS.md, then remove it here.
           """
  end
end