Add email-change permission validation for linked members
All checks were successful
continuous-integration/drone/push Build is passing

Only admins or the linked user may change a linked member's email.
- New validation EmailChangePermission (uses Actor.admin?, Loader.get_linked_user).
- Register on Member update_member; docs and gettext.
This commit is contained in:
Moritz 2026-02-03 14:35:32 +01:00
parent ad02f8914f
commit 4ea31f0f37
7 changed files with 324 additions and 28 deletions

View file

@ -0,0 +1,237 @@
defmodule Mv.Membership.MemberEmailValidationTest do
@moduledoc """
Tests for Member email-change permission validation.
When a member is linked to a user, only admins or the linked user may change
that member's email. Unlinked members and non-email updates are unaffected.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Authorization
alias Mv.Helpers.SystemActor
alias Mv.Membership
setup do
system_actor = SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_role_with_permission_set(permission_set_name, actor) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Authorization.create_role(
%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
},
actor: actor
) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
defp create_user_with_permission_set(permission_set_name, actor) do
role = create_role_with_permission_set(permission_set_name, actor)
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create(actor: actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
defp create_admin_user(actor) do
create_user_with_permission_set("admin", actor)
end
defp create_linked_member_for_user(user, actor) do
admin = create_admin_user(actor)
{:ok, member} =
Membership.create_member(
%{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
},
actor: admin
)
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
member
end
defp create_unlinked_member(actor) do
admin = create_admin_user(actor)
{:ok, member} =
Membership.create_member(
%{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
},
actor: admin
)
member
end
describe "unlinked member" do
test "normal_user can update email of unlinked member", %{actor: actor} do
normal_user = create_user_with_permission_set("normal_user", actor)
unlinked_member = create_unlinked_member(actor)
new_email = "new#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user)
assert updated.email == new_email
end
test "validation does not block when member has no linked user", %{actor: actor} do
normal_user = create_user_with_permission_set("normal_user", actor)
unlinked_member = create_unlinked_member(actor)
new_email = "other#{System.unique_integer([:positive])}@example.com"
assert {:ok, _} =
Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user)
end
end
describe "linked member another user's member" do
test "normal_user cannot update email of another user's linked member", %{actor: actor} do
user_a = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = create_user_with_permission_set("normal_user", actor)
new_email = "other#{System.unique_integer([:positive])}@example.com"
assert {:error, %Ash.Error.Invalid{} = error} =
Membership.update_member(linked_member, %{email: new_email}, actor: normal_user_b)
error_str = Exception.message(error)
assert error_str =~ "administrators"
assert error_str =~ "linked to users"
end
test "admin can update email of linked member", %{actor: actor} do
user_a = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user_a, actor)
admin = create_admin_user(actor)
new_email = "admin_changed#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: admin)
assert updated.email == new_email
end
end
describe "linked member own member" do
test "own_data user can update email of their own linked member", %{actor: actor} do
own_data_user = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(own_data_user, actor)
{:ok, own_data_user} =
Ash.get(Accounts.User, own_data_user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, own_data_user} =
Ash.load(own_data_user, :member, domain: Mv.Accounts, actor: actor)
new_email = "own_updated#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: own_data_user)
assert updated.email == new_email
end
test "normal_user with linked member can update email of that same member", %{actor: actor} do
normal_user = create_user_with_permission_set("normal_user", actor)
linked_member = create_linked_member_for_user(normal_user, actor)
{:ok, normal_user} =
Ash.get(Accounts.User, normal_user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, normal_user} = Ash.load(normal_user, :member, domain: Mv.Accounts, actor: actor)
new_email = "normal_own#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: normal_user)
assert updated.email == new_email
end
end
describe "no-op / other fields" do
test "updating only other attributes on linked member as normal_user does not trigger validation error",
%{actor: actor} do
user_a = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = create_user_with_permission_set("normal_user", actor)
assert {:ok, updated} =
Membership.update_member(linked_member, %{first_name: "UpdatedName"},
actor: normal_user_b
)
assert updated.first_name == "UpdatedName"
assert updated.email == linked_member.email
end
test "updating email of linked member as admin succeeds", %{actor: actor} do
user_a = create_user_with_permission_set("own_data", actor)
linked_member = create_linked_member_for_user(user_a, actor)
admin = create_admin_user(actor)
new_email = "admin_ok#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: admin)
assert updated.email == new_email
end
end
describe "read_only" do
test "read_only cannot update any member (policy rejects before validation)", %{actor: actor} do
read_only_user = create_user_with_permission_set("read_only", actor)
linked_member = create_linked_member_for_user(read_only_user, actor)
{:ok, read_only_user} =
Ash.get(Accounts.User, read_only_user.id,
domain: Mv.Accounts,
load: [:role],
actor: actor
)
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(linked_member, %{email: "changed@example.com"},
actor: read_only_user
)
end
end
end