Merge branch 'main' into feature/335_csv_import_ui
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
465fe5a5b1
80 changed files with 4742 additions and 6541 deletions
|
|
@ -73,6 +73,26 @@ defmodule Mv.Membership.MemberTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "Authorization" do
|
||||
@valid_attrs %{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com"
|
||||
}
|
||||
|
||||
test "user without role cannot create member" do
|
||||
# Create a user without a role
|
||||
user = Mv.Fixtures.user_fixture()
|
||||
# Ensure user has no role (nil role)
|
||||
user_without_role = %{user | role: nil}
|
||||
|
||||
# Attempt to create a member with user without role as actor
|
||||
# This should fail with Ash.Error.Forbidden containing a Policy error
|
||||
assert {:error, %Ash.Error.Forbidden{errors: [%Ash.Error.Forbidden.Policy{}]}} =
|
||||
Membership.create_member(@valid_attrs, actor: user_without_role)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function for error evaluation
|
||||
defp error_message(errors, field) do
|
||||
errors
|
||||
|
|
|
|||
|
|
@ -158,10 +158,8 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|||
|> Ash.update!()
|
||||
|
||||
# Create a member without explicitly setting membership_fee_type_id
|
||||
# Note: This test assumes that the Member resource automatically assigns
|
||||
# the default_membership_fee_type_id during creation. If this is not yet
|
||||
# implemented, this test will fail initially (which is expected in TDD).
|
||||
# For now, we skip this test as the auto-assignment feature is not yet implemented.
|
||||
# The Member resource automatically assigns the default_membership_fee_type_id
|
||||
# during creation via SetDefaultMembershipFeeType change.
|
||||
{:ok, member} =
|
||||
Ash.create(Member, %{
|
||||
first_name: "Test",
|
||||
|
|
@ -169,10 +167,8 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|
||||
# TODO: When auto-assignment is implemented, uncomment this assertion
|
||||
# assert member.membership_fee_type_id == fee_type.id
|
||||
# For now, we just verify the member was created successfully
|
||||
assert %Member{} = member
|
||||
# Verify that the default membership fee type was automatically assigned
|
||||
assert member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "include_joining_cycle is used during cycle generation" do
|
||||
|
|
|
|||
424
test/mv/accounts/user_policies_test.exs
Normal file
424
test/mv/accounts/user_policies_test.exs
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
defmodule Mv.Accounts.UserPoliciesTest do
|
||||
@moduledoc """
|
||||
Tests for User resource authorization policies.
|
||||
|
||||
Tests all 4 permission sets (own_data, read_only, normal_user, admin)
|
||||
and verifies that policies correctly enforce access control based on
|
||||
user roles and permission sets.
|
||||
"""
|
||||
# async: false because we need database commits to be visible across queries
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a role with a specific permission set
|
||||
defp create_role_with_permission_set(permission_set_name) 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
|
||||
}) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to create a user with a specific permission set
|
||||
# Returns user with role preloaded (required for authorization)
|
||||
defp create_user_with_permission_set(permission_set_name) do
|
||||
# Create role with permission set
|
||||
role = create_role_with_permission_set(permission_set_name)
|
||||
|
||||
# Create user
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Assign role to user
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|
||||
# Reload user with role preloaded (critical for authorization!)
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
# Helper to create another user (for testing access to other users)
|
||||
defp create_other_user do
|
||||
create_user_with_permission_set("own_data")
|
||||
end
|
||||
|
||||
# Shared test setup for permission sets with scope :own access
|
||||
defp setup_user_with_own_access(permission_set) do
|
||||
user = create_user_with_permission_set(permission_set)
|
||||
other_user = create_other_user()
|
||||
|
||||
# Reload user to ensure role is preloaded
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
|
||||
%{user: user, other_user: other_user}
|
||||
end
|
||||
|
||||
describe "own_data permission set (Mitglied)" do
|
||||
setup do
|
||||
setup_user_with_own_access("own_data")
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||
setup do
|
||||
setup_user_with_own_access("read_only")
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "normal_user permission set (Kassenwart)" do
|
||||
setup do
|
||||
setup_user_with_own_access("normal_user")
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "admin permission set" do
|
||||
setup do
|
||||
user = create_user_with_permission_set("admin")
|
||||
other_user = create_other_user()
|
||||
|
||||
# Reload user to ensure role is preloaded
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
|
||||
%{user: user, other_user: other_user}
|
||||
end
|
||||
|
||||
test "can read all users", %{user: user, other_user: other_user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should return all users (scope :all)
|
||||
user_ids = Enum.map(users, & &1.id)
|
||||
assert user.id in user_ids
|
||||
assert other_user.id in user_ids
|
||||
end
|
||||
|
||||
test "can read other users", %{user: user, other_user: other_user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == other_user.id
|
||||
end
|
||||
|
||||
test "can update other users", %{user: user, other_user: other_user} do
|
||||
new_email = "adminupdated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
{:ok, updated_user} =
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "can create user", %{user: user} do
|
||||
{:ok, new_user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create(actor: user)
|
||||
|
||||
assert new_user.email
|
||||
end
|
||||
|
||||
test "can destroy user", %{user: user, other_user: other_user} do
|
||||
:ok = Ash.destroy(other_user, actor: user)
|
||||
|
||||
# Verify user is deleted
|
||||
assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
|
||||
describe "AshAuthentication bypass" do
|
||||
test "register_with_password works without actor" do
|
||||
# Registration should work without actor (AshAuthentication bypass)
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "register#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert user.email
|
||||
end
|
||||
|
||||
test "register_with_rauthy works with OIDC user_info" do
|
||||
# OIDC registration should work (AshAuthentication bypass)
|
||||
user_info = %{
|
||||
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
||||
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
oauth_tokens = %{access_token: "token", refresh_token: "refresh"}
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|
||||
user_info: user_info,
|
||||
oauth_tokens: oauth_tokens
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert user.email
|
||||
assert user.oidc_id == user_info["sub"]
|
||||
end
|
||||
|
||||
test "sign_in_with_rauthy works with OIDC user_info" do
|
||||
# First create a user with OIDC ID
|
||||
user_info_create = %{
|
||||
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
||||
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
oauth_tokens = %{access_token: "token", refresh_token: "refresh"}
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|
||||
user_info: user_info_create,
|
||||
oauth_tokens: oauth_tokens
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Now test sign_in_with_rauthy (should work via AshAuthentication bypass)
|
||||
{:ok, signed_in_user} =
|
||||
Accounts.User
|
||||
|> Ash.Query.for_read(:sign_in_with_rauthy, %{
|
||||
user_info: user_info_create,
|
||||
oauth_tokens: oauth_tokens
|
||||
})
|
||||
|> Ash.read_one()
|
||||
|
||||
assert signed_in_user.id == user.id
|
||||
end
|
||||
|
||||
# NOTE: get_by_subject is tested implicitly via AshAuthentication's JWT flow.
|
||||
# Direct testing via Ash.Query.for_read(:get_by_subject) doesn't properly
|
||||
# simulate the AshAuthentication context and would require mocking JWT tokens.
|
||||
# The AshAuthentication bypass policy ensures this action works correctly
|
||||
# when called through the proper authentication flow (sign_in, token refresh, etc.).
|
||||
# Integration tests that use actual JWT tokens cover this functionality.
|
||||
end
|
||||
|
||||
describe "test environment bypass (NoActor)" do
|
||||
test "operations without actor are allowed in test environment" do
|
||||
# In test environment, NoActor check should allow operations
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "noactor#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert user.email
|
||||
|
||||
# Read should also work
|
||||
{:ok, fetched_user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts)
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
end
|
||||
end
|
||||
84
test/mv/authorization/actor_test.exs
Normal file
84
test/mv/authorization/actor_test.exs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
defmodule Mv.Authorization.ActorTest do
|
||||
@moduledoc """
|
||||
Tests for the Actor helper module.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization.Actor
|
||||
|
||||
describe "ensure_loaded/1" do
|
||||
test "returns nil when actor is nil" do
|
||||
assert Actor.ensure_loaded(nil) == nil
|
||||
end
|
||||
|
||||
test "returns actor as-is when role is already loaded" do
|
||||
# Create user with role
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Load role
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||
|
||||
# Should return as-is (no additional load)
|
||||
result = Actor.ensure_loaded(user_with_role)
|
||||
assert result.id == user.id
|
||||
assert result.role != %Ash.NotLoaded{}
|
||||
end
|
||||
|
||||
test "loads role when it's NotLoaded" do
|
||||
# Create a role first
|
||||
{:ok, role} =
|
||||
Mv.Authorization.Role
|
||||
|> Ash.Changeset.for_create(:create_role, %{
|
||||
name: "Test Role #{System.unique_integer([:positive])}",
|
||||
description: "Test role",
|
||||
permission_set_name: "own_data"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create user with role
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Assign role to user
|
||||
{:ok, user_with_role} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|
||||
# Fetch user again WITHOUT loading role (simulates "role not preloaded" scenario)
|
||||
{:ok, user_without_role_loaded} =
|
||||
Ash.get(Accounts.User, user_with_role.id, domain: Mv.Accounts)
|
||||
|
||||
# User has role as NotLoaded (relationship not preloaded)
|
||||
assert match?(%Ash.NotLoaded{}, user_without_role_loaded.role)
|
||||
|
||||
# ensure_loaded should load it
|
||||
result = Actor.ensure_loaded(user_without_role_loaded)
|
||||
assert result.id == user.id
|
||||
refute match?(%Ash.NotLoaded{}, result.role)
|
||||
assert result.role.id == role.id
|
||||
end
|
||||
|
||||
test "returns non-User actors as-is (no-op)" do
|
||||
# Create a plain map (not Mv.Accounts.User)
|
||||
other_actor = %{id: "fake", role: %Ash.NotLoaded{field: :role}}
|
||||
|
||||
# Should return as-is (pattern match doesn't apply to non-User)
|
||||
result = Actor.ensure_loaded(other_actor)
|
||||
assert result == other_actor
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -76,8 +76,10 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
|||
|
||||
{:ok, result} = HasPermission.strict_check(own_data_user, authorizer, [])
|
||||
|
||||
# Should return :unknown for :own scope (needs filter)
|
||||
assert result == :unknown
|
||||
# Should return false for :own scope without record
|
||||
# This prevents bypassing expr-based filters in bypass policies
|
||||
# The actual filtering is done via bypass policies with expr(id == ^actor(:id))
|
||||
assert result == false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -104,14 +106,16 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
|||
end
|
||||
|
||||
describe "strict_check/3 - Scope :own" do
|
||||
test "actor with scope :own returns :unknown (needs filter)" do
|
||||
test "actor with scope :own returns false (needs bypass policy with expr filter)" do
|
||||
user = create_actor("user-123", "own_data")
|
||||
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
||||
|
||||
{:ok, result} = HasPermission.strict_check(user, authorizer, [])
|
||||
|
||||
# Should return :unknown for :own scope (needs filter via auto_filter)
|
||||
assert result == :unknown
|
||||
# Should return false for :own scope without record
|
||||
# This prevents bypassing expr-based filters in bypass policies
|
||||
# The actual filtering is done via bypass policies with expr(id == ^actor(:id))
|
||||
assert result == false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -270,4 +274,44 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "strict_check/3 - Role Loading Fallback" do
|
||||
test "returns false if role is NotLoaded and cannot be loaded" do
|
||||
# Create actor with NotLoaded role
|
||||
# In real scenario, ensure_role_loaded would attempt to load via Ash.load
|
||||
# For this test, we use a simple map to verify the pattern matching works
|
||||
actor = %{
|
||||
id: "user-123",
|
||||
role: %Ash.NotLoaded{}
|
||||
}
|
||||
|
||||
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
||||
|
||||
# Should handle NotLoaded pattern and return false
|
||||
# (In real scenario, ensure_role_loaded would attempt to load, but for this test
|
||||
# we just verify the pattern matching works correctly)
|
||||
{:ok, result} = HasPermission.strict_check(actor, authorizer, [])
|
||||
assert result == false
|
||||
end
|
||||
|
||||
test "returns false if role is nil" do
|
||||
actor = %{
|
||||
id: "user-123",
|
||||
role: nil
|
||||
}
|
||||
|
||||
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
||||
|
||||
{:ok, result} = HasPermission.strict_check(actor, authorizer, [])
|
||||
assert result == false
|
||||
end
|
||||
|
||||
test "works correctly when role is already loaded" do
|
||||
actor = create_actor("user-123", "admin")
|
||||
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
||||
|
||||
{:ok, result} = HasPermission.strict_check(actor, authorizer, [])
|
||||
assert result == true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
52
test/mv/authorization/checks/no_actor_test.exs
Normal file
52
test/mv/authorization/checks/no_actor_test.exs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
defmodule Mv.Authorization.Checks.NoActorTest do
|
||||
@moduledoc """
|
||||
Tests for the NoActor Ash Policy Check.
|
||||
|
||||
This check allows actions without an actor ONLY in test environment.
|
||||
In production/dev, all operations without an actor are denied.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.Authorization.Checks.NoActor
|
||||
|
||||
describe "match?/3" do
|
||||
test "returns true when actor is nil in test environment" do
|
||||
# In test environment (config :allow_no_actor_bypass = true), NoActor allows operations
|
||||
result = NoActor.match?(nil, %{}, [])
|
||||
assert result == true
|
||||
end
|
||||
|
||||
test "returns false when actor is present" do
|
||||
actor = %{id: "user-123"}
|
||||
result = NoActor.match?(actor, %{}, [])
|
||||
assert result == false
|
||||
end
|
||||
|
||||
test "uses compile-time config (not runtime Mix.env)" do
|
||||
# The @allow_no_actor_bypass is set via Application.compile_env at compile time
|
||||
# In test.exs: config :mv, :allow_no_actor_bypass, true
|
||||
# In prod/dev: not set (defaults to false)
|
||||
# This ensures the check is release-safe (no runtime Mix.env dependency)
|
||||
result = NoActor.match?(nil, %{}, [])
|
||||
|
||||
# In test environment (as compiled), should allow
|
||||
assert result == true
|
||||
|
||||
# Note: We cannot test "production mode" here because the flag is compile-time.
|
||||
# Production safety is guaranteed by:
|
||||
# 1. Config only set in test.exs
|
||||
# 2. Default is false (fail-closed)
|
||||
# 3. No runtime environment checks
|
||||
end
|
||||
end
|
||||
|
||||
describe "describe/1" do
|
||||
test "returns description based on compile-time config" do
|
||||
description = NoActor.describe([])
|
||||
assert is_binary(description)
|
||||
|
||||
# In test environment (compiled with :allow_no_actor_bypass = true)
|
||||
assert description =~ "test environment"
|
||||
end
|
||||
end
|
||||
end
|
||||
362
test/mv/helpers/system_actor_test.exs
Normal file
362
test/mv/helpers/system_actor_test.exs
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
defmodule Mv.Helpers.SystemActorTest do
|
||||
@moduledoc """
|
||||
Tests for the SystemActor helper module.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Authorization
|
||||
alias Mv.Accounts
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper function to ensure admin role exists
|
||||
defp ensure_admin_role do
|
||||
case Authorization.list_roles() do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
||||
nil ->
|
||||
{:ok, role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
})
|
||||
|
||||
role
|
||||
|
||||
role ->
|
||||
role
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:ok, role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
})
|
||||
|
||||
role
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to ensure system user exists with admin role
|
||||
defp ensure_system_user(admin_role) do
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
Accounts.create_user!(%{email: "system@mila.local"},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to ensure admin user exists with admin role
|
||||
defp ensure_admin_user(admin_role) do
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
Accounts.create_user!(%{email: admin_email},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
|
||||
setup do
|
||||
admin_role = ensure_admin_role()
|
||||
system_user = ensure_system_user(admin_role)
|
||||
admin_user = ensure_admin_user(admin_role)
|
||||
|
||||
# Invalidate cache to ensure fresh load
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
%{admin_role: admin_role, system_user: system_user, admin_user: admin_user}
|
||||
end
|
||||
|
||||
describe "get_system_actor/0" do
|
||||
test "returns system user with admin role", %{system_user: _system_user} do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
assert %Mv.Accounts.User{} = system_actor
|
||||
assert to_string(system_actor.email) == "system@mila.local"
|
||||
assert system_actor.role != nil
|
||||
assert system_actor.role.permission_set_name == "admin"
|
||||
end
|
||||
|
||||
test "falls back to admin user if system user doesn't exist", %{admin_user: _admin_user} do
|
||||
# Delete system user if it exists
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
# Invalidate cache to force reload
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
# Should fall back to admin user
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
assert %Mv.Accounts.User{} = system_actor
|
||||
assert system_actor.role != nil
|
||||
assert system_actor.role.permission_set_name == "admin"
|
||||
# Should be admin user, not system user
|
||||
assert to_string(system_actor.email) != "system@mila.local"
|
||||
end
|
||||
|
||||
test "caches system actor for performance" do
|
||||
# First call
|
||||
actor1 = SystemActor.get_system_actor()
|
||||
|
||||
# Second call should return cached actor (same struct)
|
||||
actor2 = SystemActor.get_system_actor()
|
||||
|
||||
# Should be the same struct (cached)
|
||||
assert actor1.id == actor2.id
|
||||
end
|
||||
|
||||
test "creates system user in test environment if none exists", %{admin_role: _admin_role} do
|
||||
# In test environment, system actor should auto-create if missing
|
||||
# Delete all users to test auto-creation
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
# Invalidate cache
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
# Should auto-create system user in test environment
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
assert %Mv.Accounts.User{} = system_actor
|
||||
assert to_string(system_actor.email) == "system@mila.local"
|
||||
assert system_actor.role != nil
|
||||
assert system_actor.role.permission_set_name == "admin"
|
||||
end
|
||||
end
|
||||
|
||||
describe "invalidate_cache/0" do
|
||||
test "forces reload of system actor on next call" do
|
||||
# Get initial actor
|
||||
actor1 = SystemActor.get_system_actor()
|
||||
|
||||
# Invalidate cache
|
||||
:ok = SystemActor.invalidate_cache()
|
||||
|
||||
# Next call should reload (but should be same user)
|
||||
actor2 = SystemActor.get_system_actor()
|
||||
|
||||
# Should be same user (same ID)
|
||||
assert actor1.id == actor2.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_system_actor_result/0" do
|
||||
test "returns ok tuple with system actor" do
|
||||
assert {:ok, actor} = SystemActor.get_system_actor_result()
|
||||
assert %Mv.Accounts.User{} = actor
|
||||
assert actor.role.permission_set_name == "admin"
|
||||
end
|
||||
|
||||
test "returns error tuple when system actor cannot be loaded" do
|
||||
# Delete all users to force error
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
# In test environment, it should auto-create, so this should succeed
|
||||
# But if it fails, we should get an error tuple
|
||||
result = SystemActor.get_system_actor_result()
|
||||
|
||||
# Should either succeed (auto-created) or return error
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
end
|
||||
|
||||
describe "system_user_email/0" do
|
||||
test "returns the system user email address" do
|
||||
assert SystemActor.system_user_email() == "system@mila.local"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "raises error if admin user has no role", %{admin_user: admin_user} do
|
||||
# Remove role from admin user
|
||||
admin_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|
||||
# Delete system user to force fallback
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
# Should raise error because admin user has no role
|
||||
assert_raise RuntimeError, ~r/System actor must have a role assigned/, fn ->
|
||||
SystemActor.get_system_actor()
|
||||
end
|
||||
end
|
||||
|
||||
test "handles concurrent calls without race conditions" do
|
||||
# Delete system user and admin user to force creation
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
# Call get_system_actor concurrently from multiple processes
|
||||
tasks =
|
||||
for _ <- 1..10 do
|
||||
Task.async(fn -> SystemActor.get_system_actor() end)
|
||||
end
|
||||
|
||||
results = Task.await_many(tasks, :infinity)
|
||||
|
||||
# All should succeed and return the same actor
|
||||
assert length(results) == 10
|
||||
assert Enum.all?(results, &(&1.role.permission_set_name == "admin"))
|
||||
assert Enum.all?(results, fn actor -> to_string(actor.email) == "system@mila.local" end)
|
||||
|
||||
# All should have the same ID (same user)
|
||||
ids = Enum.map(results, & &1.id)
|
||||
assert Enum.uniq(ids) |> length() == 1
|
||||
end
|
||||
|
||||
test "raises error if system user has wrong role", %{system_user: system_user} do
|
||||
# Create a non-admin role (using read_only as it's a valid permission set)
|
||||
{:ok, read_only_role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Read Only Role",
|
||||
description: "Read-only access",
|
||||
permission_set_name: "read_only"
|
||||
})
|
||||
|
||||
# Assign wrong role to system user
|
||||
system_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
# Should raise error because system user doesn't have admin role
|
||||
assert_raise RuntimeError, ~r/System actor must have admin role/, fn ->
|
||||
SystemActor.get_system_actor()
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error if system user has no role", %{system_user: system_user} do
|
||||
# Remove role from system user
|
||||
system_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
# Should raise error because system user has no role
|
||||
assert_raise RuntimeError, ~r/System actor must have a role assigned/, fn ->
|
||||
SystemActor.get_system_actor()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -404,7 +404,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
|||
|
||||
assert chunk_result.inserted == 0
|
||||
assert chunk_result.failed == 10
|
||||
assert length(chunk_result.errors) == 0
|
||||
assert chunk_result.errors == []
|
||||
end
|
||||
|
||||
test "error capping with mixed success and failure" do
|
||||
|
|
|
|||
|
|
@ -146,8 +146,6 @@ defmodule MvWeb.ProfileNavigationTest do
|
|||
"/",
|
||||
"/members",
|
||||
"/members/new",
|
||||
"/custom_field_values",
|
||||
"/custom_field_values/new",
|
||||
"/users",
|
||||
"/users/new"
|
||||
]
|
||||
|
|
|
|||
274
test/mv_web/live/role_live/show_test.exs
Normal file
274
test/mv_web/live/role_live/show_test.exs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
defmodule MvWeb.RoleLive.ShowTest do
|
||||
@moduledoc """
|
||||
Tests for the role show page.
|
||||
|
||||
Tests cover:
|
||||
- Displaying role information
|
||||
- System role badge display
|
||||
- User count display
|
||||
- Navigation
|
||||
- Error handling
|
||||
- Delete functionality
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Authorization
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
# Helper to create a role
|
||||
defp create_role(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "Test Role #{System.unique_integer([:positive])}",
|
||||
description: "Test description",
|
||||
permission_set_name: "read_only"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
case Authorization.create_role(attrs) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to create admin user with admin role
|
||||
defp create_admin_user(conn) do
|
||||
# Create admin role
|
||||
admin_role =
|
||||
case Authorization.list_roles() do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.name == "Admin")) do
|
||||
nil ->
|
||||
# Create admin role if it doesn't exist
|
||||
create_role(%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
})
|
||||
|
||||
role ->
|
||||
role
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Create admin role if list_roles fails
|
||||
create_role(%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
})
|
||||
end
|
||||
|
||||
# Create user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Assign admin role using manage_relationship
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|
||||
# Load role for authorization checks (must be loaded for can?/3 to work)
|
||||
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
|
||||
|
||||
# Store user with role in session for LiveView
|
||||
conn = conn_with_password_user(conn, user_with_role)
|
||||
{conn, user_with_role, admin_role}
|
||||
end
|
||||
|
||||
describe "mount and display" do
|
||||
setup %{conn: conn} do
|
||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
||||
%{conn: conn}
|
||||
end
|
||||
|
||||
test "mounts successfully with valid role ID", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
assert html =~ role.name
|
||||
end
|
||||
|
||||
test "displays role name", %{conn: conn} do
|
||||
role = create_role(%{name: "Test Role Name"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
assert html =~ "Test Role Name"
|
||||
assert html =~ gettext("Name")
|
||||
end
|
||||
|
||||
test "displays role description when present", %{conn: conn} do
|
||||
role = create_role(%{description: "This is a test description"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
assert html =~ "This is a test description"
|
||||
assert html =~ gettext("Description")
|
||||
end
|
||||
|
||||
test "displays 'No description' when description is missing", %{conn: conn} do
|
||||
role = create_role(%{description: nil})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
assert html =~ gettext("No description")
|
||||
end
|
||||
|
||||
test "displays permission set name", %{conn: conn} do
|
||||
role = create_role(%{permission_set_name: "read_only"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
assert html =~ "read_only"
|
||||
assert html =~ gettext("Permission Set")
|
||||
end
|
||||
|
||||
test "displays system role badge when is_system_role is true", %{conn: conn} do
|
||||
system_role =
|
||||
Role
|
||||
|> Ash.Changeset.for_create(:create_role, %{
|
||||
name: "System Role",
|
||||
permission_set_name: "own_data"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
||||
|> Ash.create!()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
|
||||
|
||||
assert html =~ gettext("System Role")
|
||||
assert html =~ gettext("Yes")
|
||||
end
|
||||
|
||||
test "displays non-system role badge when is_system_role is false", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
assert html =~ gettext("System Role")
|
||||
assert html =~ gettext("No")
|
||||
end
|
||||
|
||||
test "displays user count", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
# User count should be displayed (might be 0 or more)
|
||||
assert html =~ gettext("User") || html =~ "0" || html =~ "users"
|
||||
end
|
||||
end
|
||||
|
||||
describe "navigation" do
|
||||
setup %{conn: conn} do
|
||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
||||
%{conn: conn}
|
||||
end
|
||||
|
||||
test "back button navigates to role list", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element(
|
||||
"a[aria-label='#{gettext("Back to roles list")}'], button[aria-label='#{gettext("Back to roles list")}']"
|
||||
)
|
||||
|> render_click()
|
||||
|
||||
assert to == "/admin/roles"
|
||||
end
|
||||
|
||||
test "edit button navigates to edit form", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element(
|
||||
"a[href='/admin/roles/#{role.id}/edit'], button[href='/admin/roles/#{role.id}/edit']"
|
||||
)
|
||||
|> render_click()
|
||||
|
||||
assert to == "/admin/roles/#{role.id}/edit"
|
||||
end
|
||||
end
|
||||
|
||||
describe "error handling" do
|
||||
setup %{conn: conn} do
|
||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
||||
%{conn: conn}
|
||||
end
|
||||
|
||||
test "redirects to role list with error for invalid role ID", %{conn: conn} do
|
||||
invalid_id = Ecto.UUID.generate()
|
||||
|
||||
# Should redirect to index with error message
|
||||
result = live(conn, "/admin/roles/#{invalid_id}")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result) or
|
||||
match?({:error, {:live_redirect, %{to: "/admin/roles"}}}, result)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete functionality" do
|
||||
setup %{conn: conn} do
|
||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
||||
%{conn: conn}
|
||||
end
|
||||
|
||||
test "delete button is not shown for system roles", %{conn: conn} do
|
||||
system_role =
|
||||
Role
|
||||
|> Ash.Changeset.for_create(:create_role, %{
|
||||
name: "System Role",
|
||||
permission_set_name: "own_data"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
||||
|> Ash.create!()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{system_role.id}")
|
||||
|
||||
# Delete button should not be visible for system roles
|
||||
refute html =~ ~r/Delete.*Role.*#{system_role.id}/i
|
||||
end
|
||||
|
||||
test "delete button is shown for non-system roles", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
# Delete button should be visible for non-system roles
|
||||
assert html =~ gettext("Delete Role") || html =~ "delete"
|
||||
end
|
||||
end
|
||||
|
||||
describe "page title" do
|
||||
setup %{conn: conn} do
|
||||
{conn, _user, _admin_role} = create_admin_user(conn)
|
||||
%{conn: conn}
|
||||
end
|
||||
|
||||
test "sets correct page title", %{conn: conn} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
# Check that page title is set (might be in title tag or header)
|
||||
assert html =~ gettext("Show Role") || html =~ role.name
|
||||
end
|
||||
end
|
||||
end
|
||||
155
test/mv_web/live/user_live/show_test.exs
Normal file
155
test/mv_web/live/user_live/show_test.exs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
defmodule MvWeb.UserLive.ShowTest do
|
||||
@moduledoc """
|
||||
Tests for the user show page.
|
||||
|
||||
Tests cover:
|
||||
- Displaying user information
|
||||
- Authentication status display
|
||||
- Linked member display
|
||||
- Navigation
|
||||
- Error handling
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
setup do
|
||||
# Create test user
|
||||
user = create_test_user(%{email: "test@example.com", oidc_id: "test123"})
|
||||
%{user: user}
|
||||
end
|
||||
|
||||
describe "mount and display" do
|
||||
test "mounts successfully with valid user ID", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert html =~ to_string(user.email)
|
||||
end
|
||||
|
||||
test "displays user email", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert html =~ to_string(user.email)
|
||||
assert html =~ gettext("Email")
|
||||
end
|
||||
|
||||
test "displays password authentication status when enabled", %{conn: conn} do
|
||||
user = create_test_user(%{email: "password-user@example.com", password: "test123"})
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert html =~ gettext("Password Authentication")
|
||||
assert html =~ gettext("Enabled")
|
||||
end
|
||||
|
||||
test "displays password authentication status when not enabled", %{conn: conn} do
|
||||
# User without password (only OIDC) - create user with OIDC only
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "oidc-only#{System.unique_integer([:positive])}@example.com",
|
||||
oidc_id: "oidc#{System.unique_integer([:positive])}",
|
||||
hashed_password: nil
|
||||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert html =~ gettext("Password Authentication")
|
||||
assert html =~ gettext("Not enabled")
|
||||
end
|
||||
|
||||
test "displays linked member when present", %{conn: conn} do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Smith",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create user and link to member
|
||||
user = create_test_user(%{email: "user@example.com"})
|
||||
|
||||
{:ok, _updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:member, member, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert html =~ gettext("Linked Member")
|
||||
assert html =~ "Alice Smith"
|
||||
assert html =~ ~r/href="[^"]*\/members\/#{member.id}"/
|
||||
end
|
||||
|
||||
test "displays 'No member linked' when no member is linked", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert html =~ gettext("Linked Member")
|
||||
assert html =~ gettext("No member linked")
|
||||
end
|
||||
end
|
||||
|
||||
describe "navigation" do
|
||||
test "back button navigates to user list", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element(
|
||||
"a[aria-label='#{gettext("Back to users list")}'], button[aria-label='#{gettext("Back to users list")}']"
|
||||
)
|
||||
|> render_click()
|
||||
|
||||
assert to == "/users"
|
||||
end
|
||||
|
||||
test "edit button navigates to edit form", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element(
|
||||
"a[href='/users/#{user.id}/edit?return_to=show'], button[href='/users/#{user.id}/edit?return_to=show']"
|
||||
)
|
||||
|> render_click()
|
||||
|
||||
assert to == "/users/#{user.id}/edit?return_to=show"
|
||||
end
|
||||
end
|
||||
|
||||
describe "error handling" do
|
||||
test "raises exception for invalid user ID", %{conn: conn} do
|
||||
invalid_id = Ecto.UUID.generate()
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# The mount function uses Ash.get! which will raise an exception
|
||||
# This is expected behavior - the LiveView doesn't handle this case
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
live(conn, ~p"/users/#{invalid_id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "page title" do
|
||||
test "sets correct page title", %{conn: conn, user: user} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||
|
||||
# Check that page title is set (might be in title tag or header)
|
||||
assert html =~ gettext("Show User") || html =~ to_string(user.email)
|
||||
end
|
||||
end
|
||||
end
|
||||
143
test/mv_web/member_live/form_error_handling_test.exs
Normal file
143
test/mv_web/member_live/form_error_handling_test.exs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||
@moduledoc """
|
||||
Tests for error handling in the member form, specifically flash message display.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
describe "error handling - flash messages" do
|
||||
test "shows flash message when member creation fails with validation error", %{conn: conn} do
|
||||
# Create a member with the same email to trigger uniqueness error
|
||||
{:ok, _existing_member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Existing",
|
||||
last_name: "Member",
|
||||
email: "duplicate@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members/new")
|
||||
|
||||
# Try to create member with duplicate email
|
||||
form_data = %{
|
||||
"member[first_name]" => "New",
|
||||
"member[last_name]" => "Member",
|
||||
"member[email]" => "duplicate@example.com"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Should show flash error message
|
||||
assert has_element?(view, "#flash-group")
|
||||
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "Fehler" or
|
||||
html =~ "failed" or html =~ "fehlgeschlagen" or
|
||||
html =~ "Validation failed" or html =~ "Validierung fehlgeschlagen"
|
||||
end
|
||||
|
||||
test "shows flash message when member creation fails with missing required fields", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members/new")
|
||||
|
||||
# Submit form with missing required fields (e.g., email)
|
||||
form_data = %{
|
||||
"member[first_name]" => "Test",
|
||||
"member[last_name]" => "User"
|
||||
# email is missing
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Should show flash error message
|
||||
assert has_element?(view, "#flash-group")
|
||||
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "Fehler" or
|
||||
html =~ "failed" or html =~ "fehlgeschlagen" or
|
||||
html =~ "Validation failed" or html =~ "Validierung fehlgeschlagen" or
|
||||
html =~ "Please correct" or html =~ "Bitte korrigieren"
|
||||
end
|
||||
|
||||
test "shows flash message when member update fails", %{conn: conn} do
|
||||
# Create a member to edit
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Original",
|
||||
last_name: "Member",
|
||||
email: "original@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create another member with different email
|
||||
{:ok, _other_member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Other",
|
||||
last_name: "Member",
|
||||
email: "other@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
||||
# Try to update with duplicate email
|
||||
form_data = %{
|
||||
"member[first_name]" => "Updated",
|
||||
"member[last_name]" => "Member",
|
||||
"member[email]" => "other@example.com"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Should show flash error message
|
||||
assert has_element?(view, "#flash-group")
|
||||
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "Fehler" or
|
||||
html =~ "failed" or html =~ "fehlgeschlagen" or
|
||||
html =~ "Validation failed" or html =~ "Validierung fehlgeschlagen"
|
||||
end
|
||||
|
||||
test "form still displays field-level validation errors when flash message is shown", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members/new")
|
||||
|
||||
# Submit form with invalid email format
|
||||
form_data = %{
|
||||
"member[first_name]" => "Test",
|
||||
"member[last_name]" => "User",
|
||||
"member[email]" => "invalid-email-format"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Should show both flash message and field-level error
|
||||
assert has_element?(view, "#flash-group")
|
||||
# Field-level errors should also be visible in the form
|
||||
assert html =~ "email" or html =~ "Email"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue