defmodule MvWeb.OidcEmailUpdateTest do @moduledoc """ Tests for OIDC email updates - when an existing OIDC user changes their email in the OIDC provider and logs in again. """ use MvWeb.ConnCase, async: true describe "OIDC user updates email to available email" do test "should succeed and update email" do # Create OIDC user {:ok, oidc_user} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "original@example.com" }) |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_123") |> Ash.create() # User logs in via OIDC with NEW email user_info = %{ "sub" => "oidc_123", "preferred_username" => "newemail@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should succeed and email should be updated assert {:ok, updated_user} = result assert updated_user.id == oidc_user.id assert to_string(updated_user.email) == "newemail@example.com" assert updated_user.oidc_id == "oidc_123" end end describe "OIDC user updates email to email of passwordless user" do test "should fail with clear error message" do # Create OIDC user {:ok, _oidc_user} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "oidcuser@example.com" }) |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_456") |> Ash.create() # Create passwordless user with target email {:ok, _passwordless_user} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "taken@example.com" }) |> Ash.create() # OIDC user tries to update email to taken email user_info = %{ "sub" => "oidc_456", "preferred_username" => "taken@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should fail with email update conflict error assert {:error, %Ash.Error.Invalid{errors: errors}} = result # Should contain error about email being registered to another account assert Enum.any?(errors, fn %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> String.contains?(message, "Cannot update email to") and String.contains?(message, "already registered to another account") _ -> false end) # Should NOT contain PasswordVerificationRequired refute Enum.any?(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) end end describe "OIDC user updates email to email of password-protected user" do test "should fail with clear error message" do # Create OIDC user {:ok, _oidc_user} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "oidcuser2@example.com" }) |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_789") |> Ash.create() # Create password user with target email (explicitly NO oidc_id) password_user = create_test_user(%{ email: "passworduser@example.com", password: "securepass123" }) # Ensure it's a password-only user {:ok, password_user} = Ash.reload(password_user) assert not is_nil(password_user.hashed_password) # Force oidc_id to be nil to avoid any confusion {:ok, password_user} = password_user |> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.force_change_attribute(:oidc_id, nil) |> Ash.update() assert is_nil(password_user.oidc_id) # OIDC user tries to update email to password user's email user_info = %{ "sub" => "oidc_789", "preferred_username" => "passworduser@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should fail with email update conflict error assert {:error, %Ash.Error.Invalid{errors: errors}} = result # Should contain error about email being registered to another account assert Enum.any?(errors, fn %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> String.contains?(message, "Cannot update email to") and String.contains?(message, "already registered to another account") _ -> false end) # Should NOT contain PasswordVerificationRequired refute Enum.any?(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) end end describe "OIDC user updates email to email of different OIDC user" do test "should fail with clear error message about different OIDC account" do # Create first OIDC user {:ok, _oidc_user1} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "oidcuser1@example.com" }) |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_aaa") |> Ash.create() # Create second OIDC user with target email {:ok, _oidc_user2} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "oidcuser2@example.com" }) |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_bbb") |> Ash.create() # First OIDC user tries to update email to second user's email user_info = %{ "sub" => "oidc_aaa", "preferred_username" => "oidcuser2@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should fail with "already linked to different OIDC account" error assert {:error, %Ash.Error.Invalid{errors: errors}} = result # Should contain error about different OIDC account assert Enum.any?(errors, fn %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> String.contains?(message, "already linked to a different OIDC account") _ -> false end) # Should NOT contain PasswordVerificationRequired refute Enum.any?(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) end end describe "New OIDC user registration scenarios (for comparison)" do test "new OIDC user with email of passwordless user triggers linking flow" do # Create passwordless user {:ok, passwordless_user} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "passwordless@example.com" }) |> Ash.create() # New OIDC user tries to register user_info = %{ "sub" => "new_oidc_999", "preferred_username" => "passwordless@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should trigger PasswordVerificationRequired (linking flow) assert {:error, %Ash.Error.Invalid{errors: errors}} = result assert Enum.any?(errors, fn %Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} -> user_id == passwordless_user.id _ -> false end) end test "new OIDC user with email of existing OIDC user shows hard error" do # Create existing OIDC user {:ok, _existing_oidc_user} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "existing@example.com" }) |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_existing") |> Ash.create() # New OIDC user tries to register with same email user_info = %{ "sub" => "oidc_new", "preferred_username" => "existing@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should fail with "already linked to different OIDC account" error assert {:error, %Ash.Error.Invalid{errors: errors}} = result assert Enum.any?(errors, fn %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> String.contains?(message, "already linked to a different OIDC account") _ -> false end) end end end