210 lines
6.7 KiB
Elixir
210 lines
6.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
|
|
|
|
describe "Passwordless user - Automatic linking via special action" do
|
|
test "passwordless user can be linked via link_passwordless_oidc action" 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()
|
|
|
|
# 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()
|
|
|
|
# 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_rauthy, %{
|
|
user_info: %{
|
|
"sub" => "auto_link_oidc_123",
|
|
"preferred_username" => "invited@example.com"
|
|
},
|
|
oauth_tokens: %{"access_token" => "test_token"}
|
|
})
|
|
|> Ash.read_one()
|
|
|
|
assert {:ok, signed_in_user} = result
|
|
assert signed_in_user.id == existing_user.id
|
|
end
|
|
|
|
test "passwordless user triggers PasswordVerificationRequired for linking flow" do
|
|
# Create passwordless user
|
|
{:ok, existing_user} =
|
|
Mv.Accounts.User
|
|
|> Ash.Changeset.for_create(:create_user, %{
|
|
email: "passwordless@example.com"
|
|
})
|
|
|> Ash.create()
|
|
|
|
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_rauthy(%{
|
|
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" 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()
|
|
|
|
# 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_rauthy(%{
|
|
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" 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()
|
|
|
|
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_rauthy(%{
|
|
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_rauthy(%{
|
|
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
|