mitgliederverwaltung/test/mv_web/controllers/oidc_passwordless_linking_test.exs
Moritz 339d37937a
Rename OIDC strategy from :rauthy to :oidc, update callback path
- Rename AshAuthentication strategy from :oidc :rauthy to :oidc :oidc;
  generated actions are now register_with_oidc / sign_in_with_oidc.
- Update config keys (:rauthy → :oidc) in dev.exs and runtime.exs.
- Update default_redirect_uri to /auth/user/oidc/callback everywhere.
- Rename Mv.Accounts helper functions accordingly.
- Update Mv.Secrets, AuthController, link_oidc_account_live and all tests.
- Update docker-compose.prod.yml, .env.example, README and docs.

IMPORTANT: OIDC providers must be updated to use the new redirect URI
/auth/user/oidc/callback instead of /auth/user/rauthy/callback.
2026-02-24 11:51:00 +01:00

217 lines
7 KiB
Elixir

defmodule MvWeb.OidcPasswordlessLinkingTest do
@moduledoc """
Tests for OIDC account linking with passwordless users.
These tests verify the behavior when a passwordless user
(e.g., invited user, user created by admin) attempts to log in via OIDC.
"""
use MvWeb.ConnCase, async: true
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Passwordless user - Automatic linking via special action" do
test "passwordless user can be linked via link_passwordless_oidc action", %{actor: actor} do
# Create user without password (e.g., invited user)
{:ok, existing_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "invited@example.com"
})
|> Ash.create(actor: actor)
# Verify user has no password and no oidc_id
assert is_nil(existing_user.hashed_password)
assert is_nil(existing_user.oidc_id)
# Link via special action (simulating what happens after first OIDC attempt)
{:ok, linked_user} =
existing_user
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: "auto_link_oidc_123",
oidc_user_info: %{
"sub" => "auto_link_oidc_123",
"preferred_username" => "invited@example.com"
}
})
|> Ash.update(actor: actor)
# User should now have oidc_id linked
assert linked_user.oidc_id == "auto_link_oidc_123"
assert linked_user.id == existing_user.id
# Now OIDC sign-in should work
result =
Mv.Accounts.User
|> Ash.Query.for_read(:sign_in_with_oidc, %{
user_info: %{
"sub" => "auto_link_oidc_123",
"preferred_username" => "invited@example.com"
},
oauth_tokens: %{"access_token" => "test_token"}
})
|> Ash.read_one(actor: actor)
assert {:ok, signed_in_user} = result
assert signed_in_user.id == existing_user.id
end
test "passwordless user triggers PasswordVerificationRequired for linking flow", %{
actor: actor
} do
# Create passwordless user
{:ok, existing_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "passwordless@example.com"
})
|> Ash.create(actor: actor)
assert is_nil(existing_user.hashed_password)
assert is_nil(existing_user.oidc_id)
# Try OIDC registration - should trigger PasswordVerificationRequired
user_info = %{
"sub" => "new_oidc_456",
"preferred_username" => "passwordless@example.com"
}
result =
Mv.Accounts.create_register_with_oidc(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
# Should fail with PasswordVerificationRequired
# LinkOidcAccountLive will auto-link without password prompt
assert {:error, %Ash.Error.Invalid{}} = result
{:error, error} = result
assert Enum.any?(error.errors, fn err ->
case err do
%Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} ->
user_id == existing_user.id
_ ->
false
end
end)
end
end
describe "User with different OIDC ID - Hard Error" do
test "user with different oidc_id gets hard error, not password verification", %{actor: actor} do
# Create user with existing OIDC ID
{:ok, _existing_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "already-linked@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|> Ash.create(actor: actor)
# Try to register with same email but different OIDC ID
user_info = %{
"sub" => "different_oidc_888",
"preferred_username" => "already-linked@example.com"
}
result =
Mv.Accounts.create_register_with_oidc(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
# Should fail with hard error
assert {:error, %Ash.Error.Invalid{}} = result
{:error, error} = result
# Should NOT be PasswordVerificationRequired
refute Enum.any?(error.errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
# Should have error message about already linked
assert Enum.any?(error.errors, fn err ->
case err do
%Ash.Error.Changes.InvalidAttribute{message: msg} ->
String.contains?(msg, "already linked to a different OIDC account")
_ ->
false
end
end)
end
test "passwordless user with different oidc_id also gets hard error", %{actor: actor} do
# Create passwordless user with OIDC ID
{:ok, existing_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "passwordless-linked@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "first_oidc_777")
|> Ash.create(actor: actor)
assert is_nil(existing_user.hashed_password)
assert existing_user.oidc_id == "first_oidc_777"
# Try to register with different OIDC ID
user_info = %{
"sub" => "second_oidc_666",
"preferred_username" => "passwordless-linked@example.com"
}
result =
Mv.Accounts.create_register_with_oidc(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
# Should be hard error, not PasswordVerificationRequired
assert {:error, %Ash.Error.Invalid{}} = result
{:error, error} = result
refute Enum.any?(error.errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
end
end
describe "Password user - Requires verification (existing behavior)" do
test "password user without oidc_id requires password verification" do
# Create password user
password_user =
create_test_user(%{
email: "password@example.com",
password: "securepass123",
oidc_id: nil
})
assert not is_nil(password_user.hashed_password)
assert is_nil(password_user.oidc_id)
# Try OIDC registration
user_info = %{
"sub" => "new_oidc_999",
"preferred_username" => "password@example.com"
}
result =
Mv.Accounts.create_register_with_oidc(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
# Should require password verification
assert {:error, %Ash.Error.Invalid{}} = result
{:error, error} = result
assert Enum.any?(error.errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
end
end
end