refactor: apply review notes
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-02-20 18:24:20 +01:00
parent bc9ea818eb
commit b41f005d9e
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
9 changed files with 110 additions and 92 deletions

View file

@ -12,16 +12,25 @@ defmodule Mv.Membership.JoinRequest do
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer] authorizers: [Ash.Policy.Authorizer]
alias Ash.Policy.Check.Builtins, as: AshBuiltins
postgres do postgres do
table "join_requests" table "join_requests"
repo Mv.Repo repo Mv.Repo
end end
actions do actions do
defaults [:read, :destroy] defaults [:destroy]
# Admin: list and get by id (used with HasPermission)
read :admin_read do
description "List and get JoinRequests; requires permission (e.g. admin / normal_user)"
primary? true
end
create :create do create :create do
primary? true primary? true
accept [ accept [
:email, :email,
:confirmation_token_hash, :confirmation_token_hash,
@ -38,15 +47,9 @@ defmodule Mv.Membership.JoinRequest do
create :confirm do create :confirm do
description "Public action: create JoinRequest after confirmation link click (actor: nil)" description "Public action: create JoinRequest after confirmation link click (actor: nil)"
accept [ accept [:email, :confirmation_token_hash, :payload]
:email,
:confirmation_token_hash, change Mv.Membership.JoinRequest.Changes.SetConfirmServerMetadata
:status,
:submitted_at,
:source,
:schema_version,
:payload
]
end end
update :update do update :update do
@ -58,12 +61,11 @@ defmodule Mv.Membership.JoinRequest do
policies do policies do
policy action(:confirm) do policy action(:confirm) do
description "Allow public confirmation (actor nil) for join flow" description "Allow public confirmation (actor nil) for join flow"
authorize_if Ash.Policy.Check.Builtins.actor_absent() authorize_if AshBuiltins.actor_absent()
end end
policy action_type(:read) do policy action(:admin_read) do
description "Allow read when actor nil (success page) or when user has permission" description "List/get JoinRequests only with permission (admin, later normal_user)"
authorize_if Ash.Policy.Check.Builtins.actor_absent()
authorize_if Mv.Authorization.Checks.HasPermission authorize_if Mv.Authorization.Checks.HasPermission
end end

View file

@ -0,0 +1,18 @@
defmodule Mv.Membership.JoinRequest.Changes.SetConfirmServerMetadata do
@moduledoc """
Ash Change that sets server-side metadata for the public :confirm action.
Client may only send :email, :confirmation_token_hash, :payload (concept §2.3.2).
This change sets: status, submitted_at, source, schema_version so they cannot be forged.
"""
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.force_change_attribute(:status, "submitted")
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:source, "public_join")
|> Ash.Changeset.force_change_attribute(:schema_version, 1)
end
end

View file

@ -80,30 +80,30 @@ defmodule Mv.Membership do
end end
resource Mv.Membership.JoinRequest do resource Mv.Membership.JoinRequest do
define :list_join_requests, action: :read define :list_join_requests, action: :admin_read
define :get_join_request, action: :read, get_by: [:id] define :get_join_request, action: :admin_read, get_by: [:id]
define :update_join_request, action: :update define :update_join_request, action: :update
define :destroy_join_request, action: :destroy define :destroy_join_request, action: :destroy
end end
end end
# Idempotent confirm: implemented in code so duplicate token returns {:ok, existing} (concept §2.3.2) # Idempotent confirm: duplicate token hits unique constraint -> return {:ok, nil} (no public read)
@doc """ @doc """
Creates a JoinRequest after confirmation link click (public action with actor: nil). Creates a JoinRequest after confirmation link click (public action with actor: nil).
Idempotent: if a JoinRequest with the same `confirmation_token_hash` already exists, Idempotent: if a JoinRequest with the same `confirmation_token_hash` already exists,
returns `{:ok, existing}` instead of creating a duplicate (per concept §2.3.2). returns `{:ok, nil}` (no record returned; no public read for security).
""" """
def confirm_join_request(attrs, opts \\ []) do def confirm_join_request(attrs, opts \\ []) do
hash = attrs[:confirmation_token_hash] || attrs["confirmation_token_hash"] case do_confirm_join_request(attrs, opts) do
{:ok, request} ->
{:ok, request}
if hash do {:error, %Ash.Error.Invalid{errors: errors}} = error ->
case get_join_request_by_confirmation_token_hash!(hash, opts) do if unique_confirmation_token_violation?(errors), do: {:ok, nil}, else: error
nil -> do_confirm_join_request(attrs, opts)
existing -> {:ok, existing} other ->
end other
else
do_confirm_join_request(attrs, opts)
end end
end end
@ -113,17 +113,12 @@ defmodule Mv.Membership do
|> Ash.create(Keyword.put(opts, :domain, __MODULE__)) |> Ash.create(Keyword.put(opts, :domain, __MODULE__))
end end
defp get_join_request_by_confirmation_token_hash!(hash, opts) do defp unique_confirmation_token_violation?(errors) do
opts = Keyword.put(opts, :domain, __MODULE__) Enum.any?(errors, fn err ->
Map.get(err, :field) == :confirmation_token_hash or
Mv.Membership.JoinRequest ((pv = Map.get(err, :private_vars)) &&
|> Ash.Query.filter(confirmation_token_hash == ^hash) (is_list(pv) and Keyword.get(pv, :constraint_type) == :unique))
|> Ash.read_one(opts) end)
|> case do
{:ok, %Mv.Membership.JoinRequest{} = existing} -> existing
{:ok, nil} -> nil
_ -> nil
end
end end
# Singleton pattern: Get the single settings record # Singleton pattern: Get the single settings record

View file

@ -269,6 +269,7 @@ defmodule Mv.Authorization.PermissionSets do
perm_all("Role") ++ perm_all("Role") ++
perm_all("Group") ++ perm_all("Group") ++
member_group_perms ++ member_group_perms ++
perm_all("JoinRequest") ++
perm_all("MembershipFeeType") ++ perm_all("MembershipFeeType") ++
perm_all("MembershipFeeCycle"), perm_all("MembershipFeeCycle"),
pages: [ pages: [

View file

@ -2608,18 +2608,3 @@ msgstr "Import"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)"
#~ msgstr "Mitglieder exportieren (CSV)"
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."

View file

@ -2609,18 +2609,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)"
#~ msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr ""

View file

@ -0,0 +1,18 @@
defmodule Mv.Repo.Migrations.AlterJoinRequestsSchemaVersionToInteger do
@moduledoc """
Aligns schema_version with Ash attribute type :integer (concept review).
"""
use Ecto.Migration
def up do
alter table(:join_requests) do
modify :schema_version, :integer, null: false
end
end
def down do
alter table(:join_requests) do
modify :schema_version, :bigint, null: false
end
end
end

View file

@ -7,21 +7,20 @@ defmodule Mv.Membership.JoinRequestTest do
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: false
alias Mv.Helpers.SystemActor
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.JoinRequest alias Mv.Membership.JoinRequest
require Ash.Query require Ash.Query
# Minimal valid attributes for the public :confirm action (per concept §2.3.2) # Client-only attributes for :confirm (server sets status, submitted_at, source, schema_version)
defp valid_confirm_attrs(opts \\ []) do defp valid_confirm_attrs(opts \\ []) do
token = Keyword.get(opts, :confirmation_token_hash, "hash_#{System.unique_integer([:positive])}") token =
Keyword.get(opts, :confirmation_token_hash, "hash_#{System.unique_integer([:positive])}")
[ [
email: "join_#{System.unique_integer([:positive])}@example.com", email: "join_#{System.unique_integer([:positive])}@example.com",
confirmation_token_hash: token, confirmation_token_hash: token,
status: "submitted",
submitted_at: DateTime.utc_now(),
source: "public_join",
schema_version: 1,
payload: %{} payload: %{}
] ]
|> Enum.into(%{}) |> Enum.into(%{})
@ -39,19 +38,36 @@ defmodule Mv.Membership.JoinRequestTest do
assert request.source == "public_join" assert request.source == "public_join"
end end
test "read with actor nil succeeds for created join request" do test "no public read: actor nil cannot read JoinRequest (by id or list)" do
attrs = valid_confirm_attrs() attrs = valid_confirm_attrs()
{:ok, created} = Membership.confirm_join_request(attrs, actor: nil) {:ok, created} = Membership.confirm_join_request(attrs, actor: nil)
assert {:ok, %JoinRequest{} = read} = get_result = Ash.get(JoinRequest, created.id, actor: nil, domain: Mv.Membership)
Ash.get(JoinRequest, created.id, actor: nil, domain: Mv.Membership)
assert read.id == created.id assert match?({:error, %Ash.Error.Forbidden{}}, get_result) or
assert read.email == created.email match?(
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}},
get_result
)
list_result = JoinRequest |> Ash.read(actor: nil, domain: Mv.Membership)
assert match?({:error, %Ash.Error.Forbidden{}}, list_result) or
match?({:error, %Ash.Error.Invalid{}}, list_result) or
list_result == {:ok, []},
"actor nil must not see any JoinRequests: got #{inspect(list_result)}"
end end
test "generic create with actor nil is forbidden" do test "generic create with actor nil is forbidden" do
attrs = valid_confirm_attrs() # Use full attrs required by :create so the only failure is policy, not validation
attrs =
valid_confirm_attrs()
|> Map.merge(%{
status: "submitted",
submitted_at: DateTime.utc_now(),
source: "public_join",
schema_version: 1
})
assert {:error, %Ash.Error.Forbidden{errors: [%Ash.Error.Forbidden.Policy{}]}} = assert {:error, %Ash.Error.Forbidden{errors: [%Ash.Error.Forbidden.Policy{}]}} =
JoinRequest JoinRequest
@ -62,25 +78,23 @@ defmodule Mv.Membership.JoinRequestTest do
describe "Idempotency (confirmation_token_hash)" do describe "Idempotency (confirmation_token_hash)" do
test "second create with same confirmation_token_hash does not create duplicate" do test "second create with same confirmation_token_hash does not create duplicate" do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
token = "idempotent_token_#{System.unique_integer([:positive])}" token = "idempotent_token_#{System.unique_integer([:positive])}"
attrs1 = valid_confirm_attrs(confirmation_token_hash: token) attrs1 = valid_confirm_attrs(confirmation_token_hash: token)
attrs2 = valid_confirm_attrs(confirmation_token_hash: token) attrs2 = valid_confirm_attrs(confirmation_token_hash: token)
attrs2 = %{attrs2 | email: "other_#{System.unique_integer([:positive])}@example.com"} attrs2 = %{attrs2 | email: "other_#{System.unique_integer([:positive])}@example.com"}
assert {:ok, first} = Membership.confirm_join_request(attrs1, actor: nil) assert {:ok, _first} = Membership.confirm_join_request(attrs1, actor: nil)
# Second call with same token: idempotent return {:ok, existing} (concept §2.3.2) # Second call with same token: idempotent return {:ok, nil} (no public read)
assert {:ok, second} = Membership.confirm_join_request(attrs2, actor: nil) assert {:ok, nil} = Membership.confirm_join_request(attrs2, actor: nil)
assert second.id == first.id, "idempotent confirm must return the existing record"
count = # Count via allowed admin read (no authorize?: false)
JoinRequest assert {:ok, list} = Membership.list_join_requests(actor: system_actor)
|> Ash.Query.filter(confirmation_token_hash == ^token) count = Enum.count(list, &(&1.confirmation_token_hash == token))
|> Ash.read!(actor: system_actor, domain: Mv.Membership, authorize?: false)
|> length()
assert count == 1, "expected exactly one JoinRequest with this confirmation_token_hash, got #{count}" assert count == 1,
"expected exactly one JoinRequest with this confirmation_token_hash, got #{count}"
end end
end end