Merge branch 'main' into feature/filter-boolean-custom-fields
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-01-23 14:41:48 +01:00
commit 672b4a8250
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
45 changed files with 3166 additions and 359 deletions

View 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

View 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

View file

@ -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

View 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

View 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