Add Mv.Release.seed_admin for admin bootstrap from ENV

Creates/updates admin user from ADMIN_EMAIL and ADMIN_PASSWORD or ADMIN_PASSWORD_FILE.
Idempotent; no fallback password in production. Called from docker entrypoint and seeds.
This commit is contained in:
Moritz 2026-02-04 16:10:45 +01:00 committed by moritz
parent b177e41882
commit e065b39ed4
2 changed files with 355 additions and 0 deletions

220
test/mv/release_test.exs Normal file
View file

@ -0,0 +1,220 @@
defmodule Mv.ReleaseTest do
@moduledoc """
Tests for release tasks (e.g. seed_admin/0).
These tests verify that the admin user is created or updated from ENV
(ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE) in an idempotent way.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Accounts.User
alias Mv.Authorization.Role
require Ash.Query
setup do
ensure_admin_role_exists()
clear_admin_env()
:ok
end
describe "seed_admin/0" do
test "without ADMIN_EMAIL does nothing (idempotent), no user created" do
clear_admin_env()
user_count_before = count_users()
Mv.Release.seed_admin()
assert count_users() == user_count_before
end
test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user does not exist: does not create user" do
System.delete_env("ADMIN_PASSWORD")
System.delete_env("ADMIN_PASSWORD_FILE")
email = "admin-no-password-#{System.unique_integer([:positive])}@test.example.com"
System.put_env("ADMIN_EMAIL", email)
on_exit(fn -> System.delete_env("ADMIN_EMAIL") end)
user_count_before = count_users()
Mv.Release.seed_admin()
assert count_users() == user_count_before,
"seed_admin must not create any user when ADMIN_PASSWORD is unset (expected #{user_count_before}, got #{count_users()})"
end
test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user exists: leaves user and role unchanged" do
email = "existing-admin-#{System.unique_integer([:positive])}@test.example.com"
System.put_env("ADMIN_EMAIL", email)
on_exit(fn -> System.delete_env("ADMIN_EMAIL") end)
{:ok, user} = create_user_with_mitglied_role(email)
role_id_before = user.role_id
Mv.Release.seed_admin()
{:ok, updated} = get_user_by_email(email)
assert updated.role_id == role_id_before
end
test "with ADMIN_EMAIL and ADMIN_PASSWORD: creates user with Admin role and sets password" do
email = "new-admin-#{System.unique_integer([:positive])}@test.example.com"
password = "SecurePassword123!"
System.put_env("ADMIN_EMAIL", email)
System.put_env("ADMIN_PASSWORD", password)
on_exit(fn ->
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD")
end)
Mv.Release.seed_admin()
assert user_exists?(email),
"seed_admin must create user when ADMIN_EMAIL and ADMIN_PASSWORD are set"
{:ok, user} = get_user_by_email(email)
assert user.role_id == admin_role_id()
assert user.hashed_password != nil
assert AshAuthentication.BcryptProvider.valid?(password, user.hashed_password)
end
test "with ADMIN_EMAIL and ADMIN_PASSWORD, user already exists: assigns Admin role and updates password" do
email = "existing-to-admin-#{System.unique_integer([:positive])}@test.example.com"
password = "NewSecurePassword456!"
System.put_env("ADMIN_EMAIL", email)
System.put_env("ADMIN_PASSWORD", password)
on_exit(fn ->
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD")
end)
{:ok, user} = create_user_with_mitglied_role(email)
assert user.role_id == mitglied_role_id()
old_hashed = user.hashed_password
Mv.Release.seed_admin()
{:ok, updated} = get_user_by_email(email)
assert updated.role_id == admin_role_id()
assert updated.hashed_password != nil
assert updated.hashed_password != old_hashed
assert AshAuthentication.BcryptProvider.valid?(password, updated.hashed_password)
end
test "with ADMIN_PASSWORD_FILE: reads password from file, same behavior as ADMIN_PASSWORD" do
email = "admin-file-#{System.unique_integer([:positive])}@test.example.com"
password = "FilePassword789!"
tmp =
Path.join(
System.tmp_dir!(),
"mv_admin_password_#{System.unique_integer([:positive])}.txt"
)
File.write!(tmp, password)
System.put_env("ADMIN_EMAIL", email)
System.put_env("ADMIN_PASSWORD_FILE", tmp)
on_exit(fn ->
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD_FILE")
File.rm(tmp)
end)
Mv.Release.seed_admin()
assert user_exists?(email), "seed_admin must create user when ADMIN_PASSWORD_FILE is set"
{:ok, user} = get_user_by_email(email)
assert AshAuthentication.BcryptProvider.valid?(password, user.hashed_password)
end
test "called twice: idempotent (no duplicate user, same state)" do
email = "idempotent-admin-#{System.unique_integer([:positive])}@test.example.com"
password = "IdempotentPassword123!"
System.put_env("ADMIN_EMAIL", email)
System.put_env("ADMIN_PASSWORD", password)
on_exit(fn ->
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD")
end)
Mv.Release.seed_admin()
{:ok, user_after_first} = get_user_by_email(email)
user_count_after_first = count_users()
Mv.Release.seed_admin()
assert count_users() == user_count_after_first
{:ok, user_after_second} = get_user_by_email(email)
assert user_after_second.id == user_after_first.id
assert user_after_second.role_id == admin_role_id()
end
end
defp clear_admin_env do
System.delete_env("ADMIN_EMAIL")
System.delete_env("ADMIN_PASSWORD")
System.delete_env("ADMIN_PASSWORD_FILE")
end
defp ensure_admin_role_exists do
case Role
|> Ash.Query.filter(name == "Admin")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
{:ok, nil} ->
Role
|> Ash.Changeset.for_create(:create_role_with_system_flag, %{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin",
is_system_role: false
})
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
_ ->
:ok
end
end
defp admin_role_id do
{:ok, role} =
Role
|> Ash.Query.filter(name == "Admin")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
role.id
end
defp mitglied_role_id do
{:ok, role} = Role.get_mitglied_role()
role.id
end
defp count_users do
User
|> Ash.read!(authorize?: false, domain: Mv.Accounts)
|> length()
end
defp user_exists?(email) do
case get_user_by_email(email) do
{:ok, _} -> true
{:error, _} -> false
end
end
defp get_user_by_email(email) do
User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
end
defp create_user_with_mitglied_role(email) do
{:ok, _} = Accounts.create_user(%{email: email}, authorize?: false)
get_user_by_email(email)
end
end