test/openagents_web/live_view_scope_test.exs

58e6347eeb72 · 8 KB

defmodule OpenAgentsWeb.LiveViewScopeTest do
  @moduledoc """
  The executable enumeration behind IDENTITY-002's LiveView event clause.

  IDENTITY-002 says a route parameter, form value, mutable GitHub login, or
  LiveView event must not select another user, owner, or conversation. The
  route half is enumerated by `OpenAgentsWeb.AuthenticatedRouteGateTest`, which
  dispatches every route `OpenAgentsWeb.RouteAuthority` classifies
  `:authenticated_browser`. A route table cannot see the event half: a
  `handle_event/3` clause resolving a record from its own params is invisible
  to it, exactly as forum moderation inside a `:public_read` route was
  invisible to it for ADMIN-001.

  Two mechanisms carry the event half, and both are enumerated here.

  **Who is acting.** `OpenAgentsWeb.UserAuth.on_mount/4` attaches a
  `:handle_event` hook in the `:ensure_authenticated` and `:ensure_admin`
  stages. The hook re-reads the account from
  `OpenAgents.Accounts.get_active_user/1` before every event, so a session
  whose account was deactivated — or demoted — between mount and click is
  halted rather than served. The population is the LiveView routes in the
  router, joined to the live session each sits in, so a LiveView placed in a
  session without the hook fails here.

  **What is being resolved.** No LiveView reaches `OpenAgents.Repo`. A view
  that builds its own query is a surface where the scope rule is restated from
  memory, which is how a handler comes to resolve a record from its own params;
  keeping every read in a context keeps the rule in one place. The population is
  read from each LiveView's compiled BEAM import table.

  **What is not enumerated.** A context function that itself takes no acting
  principal, called from a handler with a caller-supplied identifier, passes
  both tests. IDENTITY-002 records that residue rather than claiming it.
  """

  use OpenAgentsWeb.ConnCase, async: false

  import Phoenix.LiveViewTest

  alias OpenAgents.Accounts
  alias OpenAgentsWeb.RouteAuthority

  # Every live session in the router, with the `on_mount` stages it declares.
  # `:ensure_authenticated` and `:ensure_admin` are the two that attach a
  # `:handle_event` hook; `:mount_current_user` attaches none, which is why a
  # session carrying only it may serve public reads and nothing else.
  @live_sessions %{
    authenticated: [{OpenAgentsWeb.UserAuth, :ensure_authenticated}],
    components: [{OpenAgentsWeb.UserAuth, :mount_current_user}],
    docs: [{OpenAgentsWeb.UserAuth, :mount_current_user}],
    forge_issues: [{OpenAgentsWeb.UserAuth, :mount_current_user}],
    operator: [
      {OpenAgentsWeb.UserAuth, :ensure_authenticated},
      {OpenAgentsWeb.UserAuth, :ensure_admin}
    ],
    operator_chat: [
      {OpenAgentsWeb.UserAuth, :ensure_authenticated},
      {OpenAgentsWeb.UserAuth, :ensure_admin}
    ],
    public: [{OpenAgentsWeb.UserAuth, :mount_current_user}],
    repository_code: [{OpenAgentsWeb.UserAuth, :mount_current_user}]
  }

  # The stages that attach a `:handle_event` hook re-resolving the acting
  # account. Adding a stage without one, or dropping the hook from one of
  # these, is what the behavioural tests below refuse.
  @actor_guarding_stages [
    {OpenAgentsWeb.UserAuth, :ensure_authenticated},
    {OpenAgentsWeb.UserAuth, :ensure_admin}
  ]

  # LiveViews permitted to reach `OpenAgents.Repo` directly. The set is empty,
  # and it is stated rather than assumed so that admitting one is a decision
  # someone writes down.
  @repo_reaching_live_views []

  describe "who is acting" do
    test "the live sessions in the router are exactly the ones named here" do
      assert_exact_set(
        Enum.map(live_routes(), fn {_verb, _path, _view, session} ->
          {session.name, Enum.map(session.extra.on_mount, & &1.id)}
        end),
        Enum.map(@live_sessions, fn {name, stages} -> {name, stages} end),
        "is a live session in the router; say which authority stages it mounts"
      )
    end

    test "every authenticated or operator LiveView sits in an actor-guarded session" do
      classes = route_classes()

      guarded =
        for {verb, path, view, session} <- live_routes(),
            Map.get(classes, {verb, path}) in [:authenticated_browser, :operator] do
          stages = Enum.map(session.extra.on_mount, & &1.id)

          assert Enum.any?(stages, &(&1 in @actor_guarding_stages)),
                 """
                 #{inspect(view)} serves #{String.upcase(verb)} #{path}, which
                 #{inspect(RouteAuthority)} classifies
                 #{inspect(Map.get(classes, {verb, path}))}, from the
                 #{inspect(session.name)} live session. That session mounts
                 #{inspect(stages)} and none of them re-resolves the acting
                 account before an event. IDENTITY-002 says a LiveView event
                 must not select another user.
                 """

          view
        end

      assert length(Enum.uniq(guarded)) >= 20
    end

    test "a demoted operator's next event is halted rather than served" do
      operator = github_user("live-scope-operator")
      previous = Application.get_env(:openagents, :admin_github_ids)
      Application.put_env(:openagents, :admin_github_ids, [operator.github_id])
      on_exit(fn -> Application.put_env(:openagents, :admin_github_ids, previous) end)

      {:ok, view, _html} = login(operator) |> live(~p"/admin/forum/claims")

      # Still signed in, still active, no longer an operator. The claim id is
      # someone else's record, which is the shape IDENTITY-002 names.
      Application.put_env(:openagents, :admin_github_ids, [])

      assert {:error, {:redirect, %{to: "/"}}} =
               render_click(view, "approve", %{"id" => Ecto.UUID.generate()})
    end

    test "a deactivated account's next event is halted rather than served" do
      user = github_user("live-scope-deactivated")
      {:ok, view, _html} = login(user) |> live(~p"/settings/api-tokens")

      {:ok, _banned} = Accounts.ban_user(user, "identity_002_live_scope_probe")

      assert {:error, {:redirect, %{to: "/"}}} =
               render_click(view, "revoke", %{"id" => Ecto.UUID.generate()})
    end
  end

  describe "what is being resolved" do
    test "no LiveView reaches OpenAgents.Repo" do
      assert_exact_set(
        Enum.filter(live_views(), &reaches_repo?/1),
        @repo_reaching_live_views,
        "is a LiveView that queries OpenAgents.Repo instead of a context"
      )
    end

    test "the enumeration covers every LiveView the router serves" do
      routed = MapSet.new(live_routes(), fn {_verb, _path, view, _session} -> view end)
      compiled = MapSet.new(live_views())

      assert MapSet.difference(routed, compiled) |> MapSet.to_list() == [],
             "a routed LiveView was not read from the compiled module list"

      assert MapSet.size(routed) >= 40
    end
  end

  # ── population ──────────────────────────────────────────────────────────

  defp live_routes do
    for route <- OpenAgentsWeb.Router.__routes__(), route.plug == Phoenix.LiveView.Plug do
      {view, _action, _options, session} = route.metadata[:phoenix_live_view]
      {to_string(route.verb), route.path, view, session}
    end
  end

  defp route_classes do
    RouteAuthority.inventory()
    |> Enum.filter(&(&1.transport == :http))
    |> Map.new(&{{&1.verb, &1.path}, &1.class})
  end

  defp live_views do
    {:ok, modules} = :application.get_key(:openagents, :modules)
    Enum.each(modules, &Code.ensure_loaded/1)
    Enum.filter(modules, &function_exported?(&1, :__live__, 0))
  end

  # Read from the compiled import table rather than from source text, so a
  # comment cannot add a query and an alias cannot hide one.
  defp reaches_repo?(module) do
    with path when is_list(path) <- :code.which(module),
         {:ok, {^module, [imports: imports]}} <- :beam_lib.chunks(path, [:imports]) do
      Enum.any?(imports, fn {called, _function, _arity} -> called == OpenAgents.Repo end)
    else
      _unreadable -> false
    end
  end

  # ── fixtures ────────────────────────────────────────────────────────────

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

  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_web/live_view_scope_test.exs. Amend IDENTITY-002 in
           INVARIANTS.md, then add it here.
           """

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