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