From 765e395d32d4d0f60bdb3489639abb0192e561e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Sat, 1 Nov 2025 10:43:14 +0100 Subject: [PATCH 01/13] Add @permit_action decorator attribute in LiveView events [#37] --- lib/permit_phoenix/decorators/live_view.ex | 54 ++++++++++++++++++++++ lib/permit_phoenix/live_view.ex | 45 ++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 lib/permit_phoenix/decorators/live_view.ex diff --git a/lib/permit_phoenix/decorators/live_view.ex b/lib/permit_phoenix/decorators/live_view.ex new file mode 100644 index 0000000..c2ed776 --- /dev/null +++ b/lib/permit_phoenix/decorators/live_view.ex @@ -0,0 +1,54 @@ +defmodule Permit.Phoenix.Decorators.LiveView do + @moduledoc false + + def __on_definition__(env, _kind, :handle_event, args, _guards, _body) do + event_name = args |> List.first() + attribute_value = Module.get_last_attribute(env.module, :permit_action, nil) + + # event_name must be a string - this means the event handler is defined + # using pattern matching; otherwise, we cannot infer the event name from the + # function body. + + cond do + is_binary(event_name) and not is_nil(attribute_value) -> + prior_value = Module.get_attribute(env.module, :__event_mapping__, %{}) + + Module.put_attribute( + env.module, + :__event_mapping__, + Map.put(prior_value, event_name, attribute_value) + ) + + # delete the permit_action attribute to avoid mapping different event names + # to the same action. + Module.delete_attribute(env.module, :permit_action) + + not is_binary(event_name) and not is_nil(attribute_value) -> + # handle_event is not defined using pattern matching, so we cannot infer the event name from the + # function body. + # In this case, the user will have to implement event_mapping/0. + + msg = """ + @permit_action module attribute cannot be used with handle_event/3 clauses that do not pattern match directly on the event name. + + To handle events covered by this clause, please implement event_mapping/0 to map event names (strings) to Permit actions (atoms), for example: + + def event_mapping do + %{ + "store" => :create, + "patch" => :update + } + end + + Note that, in the presence of other event handlers that pattern match on the event name, mappings defined using @permit_action take precedence over mappings defined in event_mapping/0. + """ + + raise ArgumentError, msg + + true -> + :ok + end + end + + def __on_definition__(_env, _kind, _name, _args, _guards, _body), do: :ok +end diff --git a/lib/permit_phoenix/live_view.ex b/lib/permit_phoenix/live_view.ex index 8e61c59..511f85c 100644 --- a/lib/permit_phoenix/live_view.ex +++ b/lib/permit_phoenix/live_view.ex @@ -291,6 +291,11 @@ defmodule Permit.Phoenix.LiveView do quote generated: true do import unquote(__MODULE__) + Module.register_attribute(__MODULE__, :permit_action, accumulate: true) + Module.register_attribute(__MODULE__, :__event_mapping__, []) + @__event_mapping__ %{} + @on_definition Permit.Phoenix.Decorators.LiveView + if unquote(@permit_ecto_available?) do require Ecto.Query end @@ -299,8 +304,9 @@ defmodule Permit.Phoenix.LiveView do @before_compile unquote(__MODULE__) @opts unquote(opts) - @impl true - def event_mapping, do: unquote(__MODULE__).event_mapping() + # event mapping is defined in the __before_compile__ callback to ensure it is + # available to the module before the __before_compile__ callback is executed. + @before_compile unquote(__MODULE__) @impl true def handle_unauthorized(action, socket) do @@ -431,7 +437,6 @@ defmodule Permit.Phoenix.LiveView do action_grouping: 0, singular_actions: 0, use_stream?: 1, - event_mapping: 0, use_scope?: 0, scope_subject: 1 ] @@ -442,6 +447,22 @@ defmodule Permit.Phoenix.LiveView do defmacro __before_compile__(_env) do quote do + if Module.defines?(__MODULE__, {:event_mapping, 0}) do + # it appears the developer has defined their own event_mapping/0 function, + # so we will disregard the default event mapping, and only merge the developer's + # implementation with whatever was defined using @permit_action module attributes. + defoverridable event_mapping: 0 + def event_mapping, do: super() |> Map.merge(@__event_mapping__) + else + # no event_mapping/0 function defined, so we use the default event mapping + whatever + # was defined in the module using @permit_action. + @impl true + def event_mapping, + do: unquote(__MODULE__).default_event_mapping() |> Map.merge(@__event_mapping__) + + defoverridable event_mapping: 0 + end + if Module.defines?(__MODULE__, {:loader, 1}) do def use_loader?, do: true else @@ -482,8 +503,22 @@ defmodule Permit.Phoenix.LiveView do RuntimeError -> false end - @doc false - def event_mapping do + # Default event mapping will not map "save" to any action. It is not unambiguous + # whether "save" should be mapped to :create or :update. Since Phoenix generators use "save" + # for both create and update actions, it will be up to the developer to clarify the mapping using: + # + # ```elixir + # @permit_action :create + # def handle_event("save", params, socket) do + # {:noreply, socket} + # end + # + # @permit_action :update + # def handle_event("save", params, socket) do + # {:noreply, socket} + # end + # ``` + def default_event_mapping do %{ "create" => :create, "delete" => :delete, From 8a2da55ef6af96ee272ac689e711945cc313245d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 26 Nov 2025 11:15:18 +0100 Subject: [PATCH 02/13] Update docs and comments --- lib/permit_phoenix/live_view.ex | 41 +++++++++++++++---- .../live_view/authorize_hook.ex | 16 ++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/lib/permit_phoenix/live_view.ex b/lib/permit_phoenix/live_view.ex index 511f85c..4133a3a 100644 --- a/lib/permit_phoenix/live_view.ex +++ b/lib/permit_phoenix/live_view.ex @@ -137,20 +137,43 @@ defmodule Permit.Phoenix.LiveView do ## Event authorization Actions such as updating or deleting a resource are typically implemented in LiveView using `handle_event/3`. - Permit taps into `handle_event/3` processing, loads the resource with Permit.Ecto (or a loader function) based - on the event's `"id"` param and a query based on the currently resolved permissions and puts it in `assigns`. - If authorization fails, `handle_unauthorized/2` is called. + Permit taps into `handle_event/3` processing and, depending on the event's nature: + * For events carrying an `"id"` param (e.g. record deletion from an index page), **loads the record** with + Permit.Ecto (or a loader function) based on the ID param and a query based on the currently resolved + permissions and puts it in `assigns`. + * For events that do not carry an `"id"` param (e.g. updating a record with form data), **reloads the + record** currently assigned to `@loaded_resource`, using either Permit.Ecto (and the record's ID) or + the existing loader function. This is done by default to ensure permissions are evaluated against the + latest data. You can disable this behaviour by overriding `reload_on_event?/2` (or by passing the + `:reload_on_event?` option) if you prefer to reuse the already assigned record. + + Event to action mapping is given using the `@permit_action` module attribute put right before an event + handler. - Event to action mapping must be given in the `event_mapping/0` callback. There is no default mapping as - event names typically suggested by Phoenix may map to different actions (e.g. Phoenix generates `"save"` - for both `:create` and `:update` actions). + @impl true + @permit_action :update + def handle_event("save", %{"article" => article_params}, socket) do + article = socket.assigns.loaded_resource + + case MyApp.update_article(article_params) do + # ... + end + end + + In this example, the `"save"` event handler is authorized against the `:update` action on `MyApp.Article`. + + Note that there is no default mapping as event names typically suggested by Phoenix may map to different + actions (e.g. Phoenix generates `"save"` for both `:create` and `:update` actions). + + When the `handle_event/3` function is not implemented using pattern matching on the first argument, + the `event_mapping` callback must be used instead. @impl true # "delete" event maps to :delete Permit action - def event_mapping, do: %{"delete" => :delete} + def event_mapping, do: %{"delete" => :delete, "remove" => :delete} @impl true - def handle_event("delete", _params, _socket) do + def handle_event(event_name, _params, _socket) when event_name in ["delete", "remove"] do # Resource is loaded and authorized by Permit article = socket.assigns.loaded_resource @@ -162,6 +185,8 @@ defmodule Permit.Phoenix.LiveView do {:noreply, stream_delete(socket, :loaded_resources, article)} end + If authorization fails, `handle_unauthorized/2` is called. Handling authorization failure is as simple as: + @impl true def handle_unauthorized(:delete, socket) do # You actually don't need to implement it, but it's useful for defining custom behaviour. diff --git a/lib/permit_phoenix/live_view/authorize_hook.ex b/lib/permit_phoenix/live_view/authorize_hook.ex index 340ee7b..761e7fb 100644 --- a/lib/permit_phoenix/live_view/authorize_hook.ex +++ b/lib/permit_phoenix/live_view/authorize_hook.ex @@ -203,6 +203,22 @@ defmodule Permit.Phoenix.LiveView.AuthorizeHook do use_loader?: use_loader? } ) do + # In events related to single-record actions like "delete", event params typically + # contain the record ID [socket.view.id_param_name(action, socket)]. + # + # In events triggered by a form, params contain only the form's payload and not the + # record ID. This usually means that we're in a LiveView like "Edit", in which we load + # and assign the record on mount, before the user triggers the event. + # In this case, we need to assume that the record is in `assigns[:loaded_resource]`. + # + # The default behaviour with Permit.Ecto is to reload the record to ensure that another + # agent did not change the record concurrently in a way that might affect authorization. + # + # If `use_loader?` is true (or there is no Permit.Ecto), by default, we will reload the + # record using the loader function and it's on the developer to ensure event params and + # socket assigns contain everything necessary to load the record. + # + # The developer can opt out of reloading the record by setting `reload_on_event?` to false. {:authorized, records} -> {:authorized, socket From 3e26a8a6e215e1f5744e72306126e07e0d867d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 26 Nov 2025 11:32:34 +0100 Subject: [PATCH 03/13] Add reload_on_event/2 callback --- lib/permit_phoenix/live_view.ex | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/permit_phoenix/live_view.ex b/lib/permit_phoenix/live_view.ex index 4133a3a..120f084 100644 --- a/lib/permit_phoenix/live_view.ex +++ b/lib/permit_phoenix/live_view.ex @@ -261,6 +261,19 @@ defmodule Permit.Phoenix.LiveView do @callback id_struct_field_name(Types.action_group(), PhoenixTypes.socket()) :: atom() @callback unauthorized_message(PhoenixTypes.socket(), map()) :: binary() @callback event_mapping() :: map() + @doc ~S""" + For events that do not carry an `"id"` param (e.g. updating a record with form data), determines whether to reload the record before each event authorization. + + Defaults to `true`. + + ## Example + + @impl true + def reload_on_event?(_action, _socket) do + true + end + """ + @callback reload_on_event?(Types.action_group(), PhoenixTypes.socket()) :: boolean() @callback use_stream?(PhoenixTypes.socket()) :: boolean() @doc ~S""" Determines whether to use Phoenix Scopes for fetching the subject. Set to `false` in Phoenix <1.8. @@ -307,6 +320,7 @@ defmodule Permit.Phoenix.LiveView do handle_not_found: 1, unauthorized_message: 2, use_stream?: 1, + reload_on_event?: 2, use_scope?: 0, scope_subject: 1 ] @@ -413,6 +427,15 @@ defmodule Permit.Phoenix.LiveView do end end + @impl true + def reload_on_event?(action, socket) do + case unquote(opts[:reload_on_event?]) do + fun when is_function(fun) -> fun.(action, socket) + nil -> true + other -> other + end + end + @impl true def use_scope? do case unquote(opts[:use_scope?]) do From c5a7943d56b8ddd6ba8670e289d3bf36c3a252aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 26 Nov 2025 11:45:02 +0100 Subject: [PATCH 04/13] Add :update to default preload_actions in LiveView --- lib/permit_phoenix/live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/permit_phoenix/live_view.ex b/lib/permit_phoenix/live_view.ex index 120f084..7700432 100644 --- a/lib/permit_phoenix/live_view.ex +++ b/lib/permit_phoenix/live_view.ex @@ -373,7 +373,7 @@ defmodule Permit.Phoenix.LiveView do @impl true def preload_actions, - do: (unquote(opts[:preload_actions]) || []) ++ [:show, :edit, :index, :delete] + do: (unquote(opts[:preload_actions]) || []) ++ [:show, :edit, :index, :delete, :update] @impl true def fallback_path(action, socket) do From 433081a47c63a1bae5bc21f243ed9d3a6eff01f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 26 Nov 2025 15:54:08 +0100 Subject: [PATCH 05/13] Add missing defoverridable to Permit.Phoenix.Actions --- lib/permit_phoenix/actions.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/permit_phoenix/actions.ex b/lib/permit_phoenix/actions.ex index c8d8aa2..429bd56 100644 --- a/lib/permit_phoenix/actions.ex +++ b/lib/permit_phoenix/actions.ex @@ -54,6 +54,8 @@ defmodule Permit.Phoenix.Actions do def singular_actions do unquote(__MODULE__).singular_actions() end + + defoverridable grouping_schema: 0, singular_actions: 0 end end From 1f11c6fef82a28e31c7c9537cda388bbd168171a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 26 Nov 2025 16:17:06 +0100 Subject: [PATCH 06/13] Add Permit.Ecto-based reloading of @loaded_resource when event has no ID in params --- .../live_view/authorize_hook.ex | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/lib/permit_phoenix/live_view/authorize_hook.ex b/lib/permit_phoenix/live_view/authorize_hook.ex index 761e7fb..be1901e 100644 --- a/lib/permit_phoenix/live_view/authorize_hook.ex +++ b/lib/permit_phoenix/live_view/authorize_hook.ex @@ -123,20 +123,26 @@ defmodule Permit.Phoenix.LiveView.AuthorizeHook do socket |> attach_hooks(params, session) - |> authenticate_and_authorize!(action, session, params) + |> authenticate_and_authorize!(action, session, params, :handle_params) end - defp authenticate_and_authorize!(socket, action, session, params) do + defp authenticate_and_authorize!(socket, action, session, params, action_origin) do socket - |> authorize(session, action, params) + |> authorize(session, action, params, action_origin) |> respond() end - @spec authorize(PhoenixTypes.socket(), PhoenixTypes.session(), Types.action_group(), map()) :: + @spec authorize( + PhoenixTypes.socket(), + PhoenixTypes.session(), + Types.action_group(), + map(), + atom() + ) :: PhoenixTypes.live_authorization_result() - defp authorize(socket, session, action, params) do + defp authorize(socket, session, action, params, action_origin) do if action in socket.view.preload_actions() do - preload_and_authorize(socket, session, action, params) + preload_and_authorize(socket, session, action, params, action_origin) else just_authorize(socket, session, action) end @@ -165,10 +171,11 @@ defmodule Permit.Phoenix.LiveView.AuthorizeHook do PhoenixTypes.socket(), PhoenixTypes.session(), Types.action_group(), - map() + map(), + atom() ) :: PhoenixTypes.live_authorization_result() - defp preload_and_authorize(socket, session, action, params) do + defp preload_and_authorize(socket, session, action, params, action_origin) do view = socket.view use_loader? = view.use_loader?() @@ -189,6 +196,20 @@ defmodule Permit.Phoenix.LiveView.AuthorizeHook do {:loaded_resources, :loaded_resource, &resolver_module.authorize_and_preload_all!/5} end + id_param_name = view.id_param_name(action, socket) + id_struct_field_name = view.id_struct_field_name(action, socket) + + params = + if action_origin == :handle_event and singular? do + Map.put_new_lazy(params, id_param_name, fn -> + Map.get(socket.assigns[load_key], id_struct_field_name) + end) + else + params + end + + dbg(auth_function) + case auth_function.( subject, authorization_module, @@ -279,13 +300,20 @@ defmodule Permit.Phoenix.LiveView.AuthorizeHook do # has already been done in the on_mount/4 callback implementation. if Permit.Phoenix.LiveView.mounting?(socket), do: {:cont, socket}, - else: authenticate_and_authorize!(socket, socket.assigns.live_action, session, params) + else: + authenticate_and_authorize!( + socket, + socket.assigns.live_action, + session, + params, + :handle_params + ) end) |> Phoenix.LiveView.attach_hook(:event_authorization, :handle_event, fn event, params, socket -> if action = socket.view.event_mapping()[event] do - authenticate_and_authorize!(socket, action, session, params) + authenticate_and_authorize!(socket, action, session, params, :handle_event) else {:cont, socket} end From 9269cad906c9fb9782a17c25ef83637d138910cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 26 Nov 2025 18:44:24 +0100 Subject: [PATCH 07/13] Add missing reload_on_event?/2 defoverridable --- lib/permit_phoenix/live_view.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/permit_phoenix/live_view.ex b/lib/permit_phoenix/live_view.ex index 7700432..3b83015 100644 --- a/lib/permit_phoenix/live_view.ex +++ b/lib/permit_phoenix/live_view.ex @@ -486,7 +486,8 @@ defmodule Permit.Phoenix.LiveView do singular_actions: 0, use_stream?: 1, use_scope?: 0, - scope_subject: 1 + scope_subject: 1, + reload_on_event?: 2 ] |> Enum.filter(& &1) ) From 017aec45a3b8c79f7961a11f94ccfad16491ca4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 26 Nov 2025 18:47:21 +0100 Subject: [PATCH 08/13] Authorize already preloaded resource if reload_on_event? is false --- .../live_view/authorize_hook.ex | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/permit_phoenix/live_view/authorize_hook.ex b/lib/permit_phoenix/live_view/authorize_hook.ex index be1901e..69ddf84 100644 --- a/lib/permit_phoenix/live_view/authorize_hook.ex +++ b/lib/permit_phoenix/live_view/authorize_hook.ex @@ -141,10 +141,43 @@ defmodule Permit.Phoenix.LiveView.AuthorizeHook do ) :: PhoenixTypes.live_authorization_result() defp authorize(socket, session, action, params, action_origin) do - if action in socket.view.preload_actions() do - preload_and_authorize(socket, session, action, params, action_origin) + cond do + action in socket.view.singular_actions() and action_origin == :handle_event and + not socket.view.reload_on_event?(action, socket) -> + authorize_preloaded_resource(socket, session, action) + + action in socket.view.preload_actions() -> + preload_and_authorize(socket, session, action, params, action_origin) + + true -> + just_authorize(socket, session, action) + end + end + + defp authorize_preloaded_resource(socket, session, action) do + record = socket.assigns[:loaded_resource] + + if !record do + raise ~S""" + #{socket.view} has the :reload_on_event? option set to false, but in processing + an event mapped to the #{action} action, the record was not found preloaded in + @loaded_resource. + + It either must be ensured that the record is preloaded in @loaded_resource (e.g. + when the LiveView mounts), or the :reload_on_event? option must be set to true. + """ + end + + subject = get_subject(socket, session) + authorization_module = socket.view.authorization_module() + + # Check authorization on the action in general, then on the specific record + with {:authorized, socket} <- just_authorize(socket, session, action), + true <- + authorization_module.can(subject) |> authorization_module.do?(action, record) do + {:authorized, socket} else - just_authorize(socket, session, action) + _ -> {:unauthorized, socket} end end @@ -208,8 +241,6 @@ defmodule Permit.Phoenix.LiveView.AuthorizeHook do params end - dbg(auth_function) - case auth_function.( subject, authorization_module, From e6a274f7e69bf400f71e4298b9ee84abdf57fa2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Thu, 27 Nov 2025 14:52:34 +0100 Subject: [PATCH 09/13] Add tests for event authorization without record param ID --- lib/permit_phoenix/live_view.ex | 1 + test/permit/save_event_live_view_test.exs | 178 ++++++++++++++++++ test/support/ecto_fake_app/permissions.ex | 2 +- .../ecto_live_view_test/live_router.ex | 5 + .../ecto_live_view_test/save_event_live.ex | 71 +++++++ .../save_event_no_reload_live.ex | 65 +++++++ 6 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 test/permit/save_event_live_view_test.exs create mode 100644 test/support/ecto_live_view_test/save_event_live.ex create mode 100644 test/support/ecto_live_view_test/save_event_no_reload_live.ex diff --git a/lib/permit_phoenix/live_view.ex b/lib/permit_phoenix/live_view.ex index 3b83015..7b95450 100644 --- a/lib/permit_phoenix/live_view.ex +++ b/lib/permit_phoenix/live_view.ex @@ -327,6 +327,7 @@ defmodule Permit.Phoenix.LiveView do |> Enum.filter(& &1) defmacro __using__(opts) do + # credo:disable-for-next-line quote generated: true do import unquote(__MODULE__) diff --git a/test/permit/save_event_live_view_test.exs b/test/permit/save_event_live_view_test.exs new file mode 100644 index 0000000..988696a --- /dev/null +++ b/test/permit/save_event_live_view_test.exs @@ -0,0 +1,178 @@ +defmodule Permit.SaveEventLiveViewTest do + @moduledoc """ + Tests for LiveView handle_event authorization with "save" events that contain + form payloads instead of IDs. Tests both reload_on_event? behaviors: + - reload_on_event? = true (default): reloads the resource before authorization + - reload_on_event? = false: uses the already loaded resource from assigns + """ + use Permit.RepoCase + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + alias Permit.EctoLiveViewTest.{Endpoint, SaveEventLive, SaveEventNoReloadLive} + alias Permit.EctoFakeApp.{Item, Repo} + + @endpoint Endpoint + + setup do + %{users: users, items: items} = Repo.seed_data!() + + {:ok, %{users: users, items: items}} + end + + describe "save event with owner role, reload_on_event? = true (default)" do + setup [:owner_role, :init_session] + + test "can save owned item - reloads resource and authorizes", %{conn: conn} do + # Navigate to edit page for item 1 (owned by user 1) + {:ok, lv, _html} = live(conn, "/save_event_items/1/edit") + + # Verify the resource was loaded on mount + assigns = get_assigns(lv, SaveEventLive) + assert %{loaded_resource: %Item{id: 1}} = assigns + assert :unauthorized not in Map.keys(assigns) + + # Update resource to check if it is reloaded later + initial_resource = assigns[:loaded_resource] + initial_resource |> Ecto.Changeset.change(%{thread_name: "updated"}) |> Repo.update!() + + # Trigger save event with form payload (no ID in params) + lv |> element("#save") |> render_click() + + # Verify the event was processed and resource was reloaded + assigns = get_assigns(lv, SaveEventLive) + assert assigns.save_called == true + assert assigns.save_params["permission_level"] == "5" + # The loaded_resource should still be present (reloaded) + assert %Item{id: 1} = assigns.save_loaded_resource + assert assigns.save_loaded_resource.thread_name == "updated" + assert :unauthorized not in Map.keys(assigns) + end + + test "reloads resource on each save event", %{conn: conn} do + # Navigate to edit page for item 1 + {:ok, lv, _html} = live(conn, "/save_event_items/1/edit") + + # Get initial loaded resource + initial_assigns = get_assigns(lv, SaveEventLive) + initial_resource = initial_assigns.loaded_resource + + # Update resource to check if it is reloaded later + initial_resource |> Ecto.Changeset.change(%{thread_name: "updated"}) |> Repo.update!() + + # Trigger save event + lv |> element("#save") |> render_click() + + # Get the resource that was present during save + save_assigns = get_assigns(lv, SaveEventLive) + save_resource = save_assigns.save_loaded_resource + + # Both should be the same item (reloaded from DB) + assert initial_resource.id == save_resource.id + assert save_resource.id == 1 + assert save_resource.thread_name == "updated" + end + end + + describe "save event with owner role, reload_on_event? = false" do + setup [:owner_role, :init_session] + + test "can save owned item - uses preloaded resource without reload", %{conn: conn} do + # Navigate to edit page for item 1 (owned by user 1) + {:ok, lv, _html} = live(conn, "/save_event_no_reload_items/1/edit") + + # Verify the resource was loaded on mount + assigns = get_assigns(lv, SaveEventNoReloadLive) + assert %{loaded_resource: %Item{id: 1}} = assigns + assert :unauthorized not in Map.keys(assigns) + + # Update the resource: make authorization condition broken, but expect the save event to succeed + initial_resource = assigns.loaded_resource + initial_resource |> Ecto.Changeset.change(%{thread_name: "broken"}) |> Repo.update!() + + # Trigger save event with form payload (no ID in params) + lv |> element("#save") |> render_click() + + # Verify the event was processed using the preloaded resource + assigns = get_assigns(lv, SaveEventNoReloadLive) + assert assigns.save_called == true + assert assigns.save_params["permission_level"] == "5" + # The loaded_resource should be the same one from mount (not reloaded) + assert %Item{id: 1} = assigns.save_loaded_resource + assert assigns.save_loaded_resource.thread_name != "broken" + assert :unauthorized not in Map.keys(assigns) + end + end + + describe "save event with moderator role" do + setup [:moderator_1_role, :init_session] + + test "CANNOT save item updated to be unauthorized in the meantime, with reload_on_event? = true", + %{ + conn: conn + } do + # Moderator level 1 can edit items with permission_level <= 1 + {:ok, lv, _html} = live(conn, "/save_event_items/1/edit") + + assigns = get_assigns(lv, SaveEventLive) + assert %{loaded_resource: %Item{id: 1, permission_level: 1}} = assigns + assert :unauthorized not in Map.keys(assigns) + + # Update the resource: make authorization condition broken, expect the save event to fail + initial_resource = assigns.loaded_resource + initial_resource |> Ecto.Changeset.change(%{permission_level: 100}) |> Repo.update!() + + # Trigger save event + lv |> element("#save") |> render_click() + + assigns = get_assigns(lv, SaveEventLive) + assert assigns.save_called == true + assert assigns.save_loaded_resource == nil + assert :unauthorized in Map.keys(assigns) + end + + test "CAN save item updated to be unauthorized in the meantime, with reload_on_event? = false", + %{ + conn: conn + } do + # Moderator level 1 can edit items with permission_level <= 1 + {:ok, lv, _html} = live(conn, "/save_event_no_reload_items/1/edit") + + assigns = get_assigns(lv, SaveEventNoReloadLive) + assert %{loaded_resource: %Item{id: 1, permission_level: 1}} = assigns + assert :unauthorized not in Map.keys(assigns) + + # Trigger save event + lv |> element("#save") |> render_click() + + assigns = get_assigns(lv, SaveEventNoReloadLive) + assert assigns.save_called == true + assert assigns.save_loaded_resource.permission_level == 1 + assert :unauthorized not in Map.keys(assigns) + end + end + + # Helper functions for setting up test roles + + def owner_role(context) do + {:ok, Map.put(context, :roles, [:owner])} + end + + def moderator_1_role(context) do + {:ok, Map.put(context, :roles, [%{role: :moderator, level: 1}])} + end + + def init_session(%{roles: roles}) do + {:ok, + conn: + Plug.Test.init_test_session( + build_conn(), + %{"token" => "valid_token", roles: roles} + )} + end + + defp get_assigns(lv, live_module) do + live_module.run(lv, fn socket -> {:reply, socket.assigns, socket} end) + end +end diff --git a/test/support/ecto_fake_app/permissions.ex b/test/support/ecto_fake_app/permissions.ex index 8e379dd..b51cdaf 100644 --- a/test/support/ecto_fake_app/permissions.ex +++ b/test/support/ecto_fake_app/permissions.ex @@ -12,7 +12,7 @@ defmodule Permit.EctoFakeApp.Permissions do def can(:owner = _role) do permit() - |> all(Item, [user, item], owner_id: user.id) + |> all(Item, [user, item], owner_id: user.id, thread_name: {:!=, "broken"}) end def can(:function_owner = _role) do diff --git a/test/support/ecto_live_view_test/live_router.ex b/test/support/ecto_live_view_test/live_router.ex index f4fb580..47c40ea 100644 --- a/test/support/ecto_live_view_test/live_router.ex +++ b/test/support/ecto_live_view_test/live_router.ex @@ -9,6 +9,8 @@ defmodule Permit.EctoLiveViewTest.LiveRouter do alias Permit.EctoLiveViewTest.HooksLive alias Permit.EctoLiveViewTest.HooksWithCustomOptsLive alias Permit.EctoLiveViewTest.DefaultBehaviorLive + alias Permit.EctoLiveViewTest.SaveEventLive + alias Permit.EctoLiveViewTest.SaveEventNoReloadLive scope "/" do live_session :authenticated, @@ -35,6 +37,9 @@ defmodule Permit.EctoLiveViewTest.LiveRouter do live("/default_items/new", DefaultBehaviorLive, :new) live("/default_items/:id/edit", DefaultBehaviorLive, :edit) live("/default_items/:id", DefaultBehaviorLive, :show) + + live("/save_event_items/:id/edit", SaveEventLive, :edit) + live("/save_event_no_reload_items/:id/edit", SaveEventNoReloadLive, :edit) end end diff --git a/test/support/ecto_live_view_test/save_event_live.ex b/test/support/ecto_live_view_test/save_event_live.ex new file mode 100644 index 0000000..1cdd770 --- /dev/null +++ b/test/support/ecto_live_view_test/save_event_live.ex @@ -0,0 +1,71 @@ +defmodule Permit.EctoLiveViewTest.SaveEventLive do + @moduledoc """ + LiveView for testing handle_event with "save" events that contain form payloads + instead of IDs. Tests both reload_on_event? behaviors. + """ + use Phoenix.LiveView, namespace: Permit + + alias Permit.EctoFakeApp.{Authorization, Item} + alias Permit.EctoFakeApp.Item.Context + + use Permit.Phoenix.LiveView, + authorization_module: Authorization, + resource_module: Item + + @impl Permit.Phoenix.LiveView + def base_query(%{resource_module: Item, params: params}) do + id = if is_binary(params["id"]), do: String.to_integer(params["id"]), else: params["id"] + Context.filter_by_id(Item, id) + end + + @impl Permit.Phoenix.LiveView + def handle_unauthorized(_action, socket), do: {:cont, assign(socket, :unauthorized, true)} + + @impl true + @spec render(any) :: Phoenix.LiveView.Rendered.t() + def render(assigns) do + ~H""" +
+ + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, mounted: true)} + end + + @impl true + @permit_action :update + def handle_event("save", params, socket) do + # Store the params and loaded_resource for test verification + socket = + socket + |> assign(:save_called, true) + |> assign(:save_params, params) + |> assign(:save_loaded_resource, socket.assigns[:loaded_resource]) + + {:noreply, socket} + end + + def handle_event("cancel", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_call({:run, func}, _, socket), do: func.(socket) + + @impl true + def handle_info({:run, func}, socket), do: func.(socket) + + def run(lv, func) do + GenServer.call(lv.pid, {:run, func}) + end +end diff --git a/test/support/ecto_live_view_test/save_event_no_reload_live.ex b/test/support/ecto_live_view_test/save_event_no_reload_live.ex new file mode 100644 index 0000000..78a22fc --- /dev/null +++ b/test/support/ecto_live_view_test/save_event_no_reload_live.ex @@ -0,0 +1,65 @@ +defmodule Permit.EctoLiveViewTest.SaveEventNoReloadLive do + @moduledoc """ + LiveView for testing handle_event with "save" events that contain form payloads + with reload_on_event? set to false (uses preloaded resource). + """ + use Phoenix.LiveView, namespace: Permit + + alias Permit.EctoFakeApp.{Authorization, Item} + + use Permit.Phoenix.LiveView, + authorization_module: Authorization, + resource_module: Item, + reload_on_event?: false + + @impl Permit.Phoenix.LiveView + def handle_unauthorized(_action, socket), do: {:cont, assign(socket, :unauthorized, true)} + + @impl true + @spec render(any) :: Phoenix.LiveView.Rendered.t() + def render(assigns) do + ~H""" +
+ + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, mounted: true)} + end + + @impl true + @permit_action :update + def handle_event("save", params, socket) do + # Store the params and loaded_resource for test verification + socket = + socket + |> assign(:save_called, true) + |> assign(:save_params, params) + |> assign(:save_loaded_resource, socket.assigns[:loaded_resource]) + + {:noreply, socket} + end + + def handle_event("cancel", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_call({:run, func}, _, socket), do: func.(socket) + + @impl true + def handle_info({:run, func}, socket), do: func.(socket) + + def run(lv, func) do + GenServer.call(lv.pid, {:run, func}) + end +end From 8dc1ea428a5986e57307cf28b70911901a683927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 3 Dec 2025 21:56:17 +0100 Subject: [PATCH 10/13] Add event mapping tests for Ecto and loader functions; bump Permit to 0.3.1 --- mix.exs | 2 +- mix.lock | 2 +- ...exs => ecto_save_event_live_view_test.exs} | 2 +- .../non_ecto_save_event_live_view_test.exs | 101 ++++++++++++++++++ test/support/ecto_fake_app/repo.ex | 17 +-- test/support/ecto_fake_app/seed_data.ex | 20 ++++ .../ecto_live_view_test/save_event_live.ex | 7 -- test/support/non_ecto_fake_app/seed_data.ex | 20 ++++ .../non_ecto_live_view_test/live_router.ex | 5 + .../save_event_loader_live.ex | 96 +++++++++++++++++ .../save_event_loader_no_reload_live.ex | 98 +++++++++++++++++ 11 files changed, 347 insertions(+), 23 deletions(-) rename test/permit/{save_event_live_view_test.exs => ecto_save_event_live_view_test.exs} (99%) create mode 100644 test/permit/non_ecto_save_event_live_view_test.exs create mode 100644 test/support/ecto_fake_app/seed_data.ex create mode 100644 test/support/non_ecto_fake_app/seed_data.ex create mode 100644 test/support/non_ecto_live_view_test/save_event_loader_live.ex create mode 100644 test/support/non_ecto_live_view_test/save_event_loader_no_reload_live.ex diff --git a/mix.exs b/mix.exs index 2f35ab5..eeb6d0d 100644 --- a/mix.exs +++ b/mix.exs @@ -67,7 +67,7 @@ defmodule Permit.Phoenix.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:permit, "~> 0.3"}, + {:permit, "~> 0.3.1"}, {:permit_ecto, "~> 0.2", optional: true}, {:ecto, "~> 3.0", optional: true}, {:ecto_sql, "~> 3.0", optional: true}, diff --git a/mix.lock b/mix.lock index c3ce637..8b8f356 100644 --- a/mix.lock +++ b/mix.lock @@ -24,7 +24,7 @@ "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "permit": {:hex, :permit, "0.3.0", "9f54f86e9e19cbccd0779c68985a9b79eb9892a826d2edeb2997c60efe7a9f77", [:mix], [], "hexpm", "aac92428febf4e3856b90a267126a0c68183a86d7785ef70c9ea4bc07cc7764b"}, + "permit": {:hex, :permit, "0.3.1", "9c900180ea11b96a9e10fb8e469762848d6f03ed6f3e6006edc3c2f1744cd2af", [:mix], [], "hexpm", "3cb71b5b2ed8722b281acbacc576a0578a28b022cb859f89f7c44de8b0c9a2cf"}, "permit_ecto": {:hex, :permit_ecto, "0.2.4", "bb087a3bbb8caafbd6247d357bb98800f979592718965ddad026f623bb942bbc", [:mix], [{:ecto, ">= 3.11.2 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, ">= 3.11.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:permit, "~> 0.2", [hex: :permit, repo: "hexpm", optional: false]}], "hexpm", "4cc4a600d7331483674f5837a3f203d7a9b1cc1faf805a49f9ff5fd9ccc21ee9"}, "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, diff --git a/test/permit/save_event_live_view_test.exs b/test/permit/ecto_save_event_live_view_test.exs similarity index 99% rename from test/permit/save_event_live_view_test.exs rename to test/permit/ecto_save_event_live_view_test.exs index 988696a..4b20fed 100644 --- a/test/permit/save_event_live_view_test.exs +++ b/test/permit/ecto_save_event_live_view_test.exs @@ -1,4 +1,4 @@ -defmodule Permit.SaveEventLiveViewTest do +defmodule Permit.EctoSaveEventLiveViewTest do @moduledoc """ Tests for LiveView handle_event authorization with "save" events that contain form payloads instead of IDs. Tests both reload_on_event? behaviors: diff --git a/test/permit/non_ecto_save_event_live_view_test.exs b/test/permit/non_ecto_save_event_live_view_test.exs new file mode 100644 index 0000000..be31ea2 --- /dev/null +++ b/test/permit/non_ecto_save_event_live_view_test.exs @@ -0,0 +1,101 @@ +defmodule Permit.NonEctoSaveEventLiveViewTest do + @moduledoc """ + Tests for LiveView handle_event authorization with "save" events that contain + form payloads instead of IDs. Tests both reload_on_event? behaviors: + - reload_on_event? = true (default): reloads the resource before authorization + - reload_on_event? = false: uses the already loaded resource from assigns + """ + use Permit.RepoCase + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + alias Permit.NonEctoLiveViewTest.{Endpoint, SaveEventLoaderLive, SaveEventLoaderNoReloadLive} + alias Permit.NonEctoFakeApp.Item + + @endpoint Endpoint + + setup do + users = Permit.NonEctoFakeApp.SeedData.users() + items = Permit.NonEctoFakeApp.SeedData.items() + + {:ok, %{users: users, items: items}} + end + + describe "save event with moderator role" do + setup [:moderator_1_role, :init_session] + + test "CANNOT save item updated to be unauthorized in the meantime, with reload_on_event? = true", + %{ + conn: conn + } do + # Moderator level 1 can edit items with permission_level <= 1 + {:ok, lv, _html} = live(conn, "/save_event_items/1/edit") + + assigns = get_assigns(lv, SaveEventLoaderLive) + assert %{loaded_resource: %Item{id: 1, permission_level: 1}} = assigns + assert :unauthorized not in Map.keys(assigns) + + # Update the resource: make authorization condition broken, expect the save event to fail + merge_assigns(lv, SaveEventLoaderLive, %{dirty: true}) + + # Trigger save event + lv |> element("#save") |> render_click() + + assigns = get_assigns(lv, SaveEventLoaderLive) + assert assigns.save_called == true + assert assigns.save_loaded_resource == nil + assert :unauthorized in Map.keys(assigns) + end + + test "CAN save item updated to be unauthorized in the meantime, with reload_on_event? = false", + %{ + conn: conn + } do + # Moderator level 1 can edit items with permission_level <= 1 + {:ok, lv, _html} = live(conn, "/save_event_no_reload_items/1/edit") + + assigns = get_assigns(lv, SaveEventLoaderNoReloadLive) + assert %{loaded_resource: %Item{id: 1, permission_level: 1}} = assigns + assert :unauthorized not in Map.keys(assigns) + + # Update the resource: make authorization condition broken, but expect the save event to pass + merge_assigns(lv, SaveEventLoaderLive, %{dirty: true}) + + # Trigger save event + lv |> element("#save") |> render_click() + + assigns = get_assigns(lv, SaveEventLoaderNoReloadLive) + assert assigns.save_called == true + # Unchanged permission level - not reloaded + assert assigns.save_loaded_resource.permission_level == 1 + assert :unauthorized not in Map.keys(assigns) + end + end + + # Helper functions for setting up test roles + + def moderator_1_role(context) do + {:ok, Map.put(context, :roles, [%{role: :moderator, level: 1}])} + end + + def init_session(%{roles: roles}) do + {:ok, + conn: + Plug.Test.init_test_session( + build_conn(), + %{"token" => "valid_token", roles: roles} + )} + end + + defp get_assigns(lv, live_module) do + live_module.run(lv, fn socket -> {:reply, socket.assigns, socket} end) + end + + defp merge_assigns(lv, live_module, new_assigns) when is_map(new_assigns) do + live_module.run(lv, fn socket -> + {:reply, Map.merge(socket.assigns, new_assigns), + socket |> Phoenix.Component.assign(new_assigns)} + end) + end +end diff --git a/test/support/ecto_fake_app/repo.ex b/test/support/ecto_fake_app/repo.ex index 8e1b7c4..fa47a4f 100644 --- a/test/support/ecto_fake_app/repo.ex +++ b/test/support/ecto_fake_app/repo.ex @@ -3,21 +3,12 @@ defmodule Permit.EctoFakeApp.Repo do otp_app: :permit_phoenix, adapter: Ecto.Adapters.Postgres - alias Permit.EctoFakeApp.{Item, Repo, User} + alias Permit.EctoFakeApp.Repo + alias Permit.EctoFakeApp.SeedData def seed_data! do - users = [ - %User{id: 1} |> Repo.insert!(), - %User{id: 2} |> Repo.insert!(), - %User{id: 3} |> Repo.insert!() - ] - - items = [ - %Item{id: 1, owner_id: 1, permission_level: 1} |> Repo.insert!(), - %Item{id: 2, owner_id: 2, permission_level: 2, thread_name: "dmt"} |> Repo.insert!(), - %Item{id: 3, owner_id: 3, permission_level: 3} |> Repo.insert!() - ] - + users = SeedData.users() |> Enum.map(&Repo.insert!(&1)) + items = SeedData.items() |> Enum.map(&Repo.insert!(&1)) %{users: users, items: items} end end diff --git a/test/support/ecto_fake_app/seed_data.ex b/test/support/ecto_fake_app/seed_data.ex new file mode 100644 index 0000000..35fbe5f --- /dev/null +++ b/test/support/ecto_fake_app/seed_data.ex @@ -0,0 +1,20 @@ +defmodule Permit.EctoFakeApp.SeedData do + @moduledoc false + alias Permit.EctoFakeApp.User + alias Permit.EctoFakeApp.Item + + @users [ + %User{id: 1}, + %User{id: 2}, + %User{id: 3} + ] + + @items [ + %Item{id: 1, owner_id: 1, permission_level: 1}, + %Item{id: 2, owner_id: 2, permission_level: 2, thread_name: "dmt"}, + %Item{id: 3, owner_id: 3, permission_level: 3} + ] + + def users, do: @users + def items, do: @items +end diff --git a/test/support/ecto_live_view_test/save_event_live.ex b/test/support/ecto_live_view_test/save_event_live.ex index 1cdd770..d969157 100644 --- a/test/support/ecto_live_view_test/save_event_live.ex +++ b/test/support/ecto_live_view_test/save_event_live.ex @@ -6,18 +6,11 @@ defmodule Permit.EctoLiveViewTest.SaveEventLive do use Phoenix.LiveView, namespace: Permit alias Permit.EctoFakeApp.{Authorization, Item} - alias Permit.EctoFakeApp.Item.Context use Permit.Phoenix.LiveView, authorization_module: Authorization, resource_module: Item - @impl Permit.Phoenix.LiveView - def base_query(%{resource_module: Item, params: params}) do - id = if is_binary(params["id"]), do: String.to_integer(params["id"]), else: params["id"] - Context.filter_by_id(Item, id) - end - @impl Permit.Phoenix.LiveView def handle_unauthorized(_action, socket), do: {:cont, assign(socket, :unauthorized, true)} diff --git a/test/support/non_ecto_fake_app/seed_data.ex b/test/support/non_ecto_fake_app/seed_data.ex new file mode 100644 index 0000000..2874a57 --- /dev/null +++ b/test/support/non_ecto_fake_app/seed_data.ex @@ -0,0 +1,20 @@ +defmodule Permit.NonEctoFakeApp.SeedData do + @moduledoc false + alias Permit.NonEctoFakeApp.User + alias Permit.NonEctoFakeApp.Item + + @users [ + %User{id: 1}, + %User{id: 2}, + %User{id: 3} + ] + + @items [ + %Item{id: 1, owner_id: 1, permission_level: 1}, + %Item{id: 2, owner_id: 2, permission_level: 2, thread_name: "dmt"}, + %Item{id: 3, owner_id: 3, permission_level: 3} + ] + + def users, do: @users + def items, do: @items +end diff --git a/test/support/non_ecto_live_view_test/live_router.ex b/test/support/non_ecto_live_view_test/live_router.ex index 46ca2d6..551e671 100644 --- a/test/support/non_ecto_live_view_test/live_router.ex +++ b/test/support/non_ecto_live_view_test/live_router.ex @@ -3,12 +3,17 @@ defmodule Permit.NonEctoLiveViewTest.LiveRouter do use Phoenix.Router import Phoenix.LiveView.Router alias Permit.NonEctoLiveViewTest.HooksLive + alias Permit.NonEctoLiveViewTest.SaveEventLoaderLive + alias Permit.NonEctoLiveViewTest.SaveEventLoaderNoReloadLive live_session :authenticated, on_mount: Permit.Phoenix.LiveView.AuthorizeHook do live("/items", HooksLive, :index) live("/items/new", HooksLive, :new) live("/items/:id/edit", HooksLive, :edit) live("/items/:id", HooksLive, :show) + + live("/save_event_items/:id/edit", SaveEventLoaderLive, :edit) + live("/save_event_no_reload_items/:id/edit", SaveEventLoaderNoReloadLive, :edit) end def session(%Plug.Conn{}, extra), do: Map.merge(extra, %{"called" => true}) diff --git a/test/support/non_ecto_live_view_test/save_event_loader_live.ex b/test/support/non_ecto_live_view_test/save_event_loader_live.ex new file mode 100644 index 0000000..73acaf9 --- /dev/null +++ b/test/support/non_ecto_live_view_test/save_event_loader_live.ex @@ -0,0 +1,96 @@ +defmodule Permit.NonEctoLiveViewTest.SaveEventLoaderLive do + @moduledoc """ + LiveView for testing handle_event with "save" events that contain form payloads + instead of IDs. Tests both reload_on_event? behaviors. + """ + use Phoenix.LiveView, namespace: Permit + + alias Permit.NonEctoFakeApp.{Authorization, Item, User} + alias Permit.NonEctoFakeApp.SeedData + + use Permit.Phoenix.LiveView, + authorization_module: Authorization, + resource_module: Item, + use_scope?: false + + @items SeedData.items() + + @impl Permit.Phoenix.LiveView + def loader(%{params: %{"id" => id}, socket: socket}) do + @items + |> Enum.find(&("#{&1.id}" == "#{id}")) + |> then(fn found_item -> + # the @dirty assign is set by the test to indicate that the item has been updated + # concurrently + + if socket.assigns[:dirty], + do: Map.put(found_item, :permission_level, 100), + else: found_item + end) + end + + def loader(%{action: :index}) do + @items + end + + def loader(_), do: nil + + @impl true + def fetch_subject(_socket, session) do + case session["token"] do + "valid_token" -> %User{id: 1, roles: session["roles"] || []} + _ -> nil + end + end + + @impl Permit.Phoenix.LiveView + def handle_unauthorized(_action, socket), do: {:cont, assign(socket, :unauthorized, true)} + + @impl true + @spec render(any) :: Phoenix.LiveView.Rendered.t() + def render(assigns) do + ~H""" +
+ + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, mounted: true)} + end + + @impl true + @permit_action :update + def handle_event("save", params, socket) do + # Store the params and loaded_resource for test verification + socket = + socket + |> assign(:save_called, true) + |> assign(:save_params, params) + |> assign(:save_loaded_resource, socket.assigns[:loaded_resource]) + + {:noreply, socket} + end + + def handle_event("cancel", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_call({:run, func}, _, socket), do: func.(socket) + + @impl true + def handle_info({:run, func}, socket), do: func.(socket) + + def run(lv, func) do + GenServer.call(lv.pid, {:run, func}) + end +end diff --git a/test/support/non_ecto_live_view_test/save_event_loader_no_reload_live.ex b/test/support/non_ecto_live_view_test/save_event_loader_no_reload_live.ex new file mode 100644 index 0000000..1728b63 --- /dev/null +++ b/test/support/non_ecto_live_view_test/save_event_loader_no_reload_live.ex @@ -0,0 +1,98 @@ +defmodule Permit.NonEctoLiveViewTest.SaveEventLoaderNoReloadLive do + @moduledoc """ + LiveView for testing handle_event with "save" events that contain form payloads + instead of IDs. Tests both reload_on_event? behaviors. + """ + use Phoenix.LiveView, namespace: Permit + + alias Permit.NonEctoFakeApp.{Authorization, Item, User} + alias Permit.NonEctoFakeApp.SeedData + + use Permit.Phoenix.LiveView, + authorization_module: Authorization, + resource_module: Item, + use_scope?: false, + reload_on_event?: false + + @items SeedData.items() + + @impl Permit.Phoenix.LiveView + def loader(%{params: %{"id" => id}, socket: socket}) do + @items + |> Enum.find(&("#{&1.id}" == "#{id}")) + |> then(fn found_item -> + # the @dirty assign is set by the test to indicate that the item has been updated + # concurrently. In this case, the loader should be coded to return the updated item, + # but the test should ensure that the loader is NOT called. + + if socket.assigns[:dirty], + do: Map.put(found_item, :permission_level, 100), + else: found_item + end) + end + + def loader(%{action: :index}) do + @items + end + + def loader(_), do: nil + + @impl true + def fetch_subject(_socket, session) do + case session["token"] do + "valid_token" -> %User{id: 1, roles: session["roles"] || []} + _ -> nil + end + end + + @impl Permit.Phoenix.LiveView + def handle_unauthorized(_action, socket), do: {:cont, assign(socket, :unauthorized, true)} + + @impl true + @spec render(any) :: Phoenix.LiveView.Rendered.t() + def render(assigns) do + ~H""" +
+ + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, mounted: true)} + end + + @impl true + @permit_action :update + def handle_event("save", params, socket) do + # Store the params and loaded_resource for test verification + socket = + socket + |> assign(:save_called, true) + |> assign(:save_params, params) + |> assign(:save_loaded_resource, socket.assigns[:loaded_resource]) + + {:noreply, socket} + end + + def handle_event("cancel", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_call({:run, func}, _, socket), do: func.(socket) + + @impl true + def handle_info({:run, func}, socket), do: func.(socket) + + def run(lv, func) do + GenServer.call(lv.pid, {:run, func}) + end +end From 78bfeaecf4c1950cb6f0eba5804165a4006ccc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 3 Dec 2025 22:16:35 +0100 Subject: [PATCH 11/13] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf82634..c166c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `put_flash(:error, socket.view.unauthorized_message(action, socket)` is always done, - `push_navigate(socket, to: socket.view.fallback_path(action, socket))` is done if the LiveView is in the mounting phase, - `fallback_path` defaults to the current `_live_referer` path if available, otherwise it is `/`. This means that if using the [link](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#link/1) component with `:navigate` option within the current session, we will still be able to navigate back to the currently displayed page, even though it will go through the mounting phase. +- Permit dependency bumped to 0.3.1 to fix curiosum-dev/permit#49. ## [0.3.1] From 4e6d1bd937f5acbddd916b3d3400469f83421945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Thu, 4 Dec 2025 10:48:49 +0100 Subject: [PATCH 12/13] Raise on invalid types in :reload_on_event? and :use_scope? opts --- lib/permit_phoenix/live_view.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/permit_phoenix/live_view.ex b/lib/permit_phoenix/live_view.ex index 7b95450..3269707 100644 --- a/lib/permit_phoenix/live_view.ex +++ b/lib/permit_phoenix/live_view.ex @@ -432,8 +432,9 @@ defmodule Permit.Phoenix.LiveView do def reload_on_event?(action, socket) do case unquote(opts[:reload_on_event?]) do fun when is_function(fun) -> fun.(action, socket) - nil -> true - other -> other + value when value in [nil, true] -> true + false -> false + _ -> raise ":reload_on_event? must be a function or a boolean" end end @@ -441,8 +442,9 @@ defmodule Permit.Phoenix.LiveView do def use_scope? do case unquote(opts[:use_scope?]) do fun when is_function(fun) -> fun.() - nil -> true - other -> other + value when value in [nil, true] -> true + false -> false + _ -> raise ":use_scope? must be a function or a boolean" end end From c4c5af76e250857243b499add1ae30e5011dab41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Thu, 4 Dec 2025 10:58:21 +0100 Subject: [PATCH 13/13] Disuse pipe operator --- lib/permit_phoenix/decorators/live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/permit_phoenix/decorators/live_view.ex b/lib/permit_phoenix/decorators/live_view.ex index c2ed776..e57d632 100644 --- a/lib/permit_phoenix/decorators/live_view.ex +++ b/lib/permit_phoenix/decorators/live_view.ex @@ -2,7 +2,7 @@ defmodule Permit.Phoenix.Decorators.LiveView do @moduledoc false def __on_definition__(env, _kind, :handle_event, args, _guards, _body) do - event_name = args |> List.first() + event_name = List.first(args) attribute_value = Module.get_last_attribute(env.module, :permit_action, nil) # event_name must be a string - this means the event handler is defined