defmodule MvWeb.OidcPasswordLinkingTest do @moduledoc """ Tests for OIDC account linking when email collision occurs. This test suite verifies the security flow when an OIDC login attempts to use an email that already exists in the system with a password account. """ use MvWeb.ConnCase, async: true require Ash.Query describe "OIDC login with existing email (no oidc_id) - Email Collision" do @tag :test_proposal test "OIDC register with existing password user email fails with PasswordVerificationRequired" do # Create password-only user existing_user = create_test_user(%{ email: "existing@example.com", password: "securepassword123", oidc_id: nil }) # Try OIDC registration with same email user_info = %{ "sub" => "new_oidc_12345", "preferred_username" => "existing@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Should fail with PasswordVerificationRequired error assert {:error, %Ash.Error.Invalid{errors: errors}} = result # Check that the error is our custom PasswordVerificationRequired password_verification_error = Enum.find(errors, fn err -> err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired end) assert password_verification_error != nil, "Should contain PasswordVerificationRequired error" assert password_verification_error.user_id == existing_user.id end @tag :test_proposal test "PasswordVerificationRequired error contains necessary context" do existing_user = create_test_user(%{ email: "test@example.com", password: "password123", oidc_id: nil }) user_info = %{ "sub" => "oidc_99999", "preferred_username" => "test@example.com" } {:error, %Ash.Error.Invalid{errors: errors}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) password_error = Enum.find(errors, fn err -> err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired end) # Verify error contains all necessary context assert password_error.user_id == existing_user.id assert password_error.oidc_user_info["sub"] == "oidc_99999" assert password_error.oidc_user_info["preferred_username"] == "test@example.com" end @tag :test_proposal test "after successful password verification, oidc_id can be set" do # Create password user user = create_test_user(%{ email: "link@example.com", password: "mypassword123", oidc_id: nil }) # Simulate password verification passed, now link OIDC user_info = %{ "sub" => "linked_oidc_555", "preferred_username" => "link@example.com" } # Use the link_oidc_id action {:ok, updated_user} = Mv.Accounts.User |> Ash.Query.filter(id == ^user.id) |> Ash.read_one!() |> Ash.Changeset.for_update(:link_oidc_id, %{ oidc_id: user_info["sub"], oidc_user_info: user_info }) |> Ash.update() assert updated_user.id == user.id assert updated_user.oidc_id == "linked_oidc_555" assert to_string(updated_user.email) == "link@example.com" # Password should still exist assert updated_user.hashed_password == user.hashed_password end @tag :test_proposal test "password verification with wrong password keeps oidc_id as nil" do # This test verifies that if password verification fails, # the oidc_id should NOT be set user = create_test_user(%{ email: "secure@example.com", password: "correctpassword", oidc_id: nil }) # This test verifies the CONCEPT that wrong password should prevent linking # In practice, the password verification happens BEFORE calling link_oidc_id # So we just verify that the user still has no oidc_id # Attempt to verify with wrong password would fail in the controller/LiveView # before link_oidc_id is called, so here we just verify the user state # User should still have no oidc_id (no linking happened) {:ok, unchanged_user} = Ash.get(Mv.Accounts.User, user.id) assert is_nil(unchanged_user.oidc_id) assert unchanged_user.hashed_password == user.hashed_password end end describe "OIDC login with email of user having different oidc_id - Account Conflict" do @tag :test_proposal test "OIDC register with email of user having different oidc_id fails" do # User already linked to OIDC provider A _existing_user = create_test_user(%{ email: "linked@example.com", oidc_id: "oidc_provider_a_123" }) # Someone tries to register with OIDC provider B using same email user_info = %{ # Different OIDC ID! "sub" => "oidc_provider_b_456", "preferred_username" => "linked@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Should fail - cannot link different OIDC account to same email assert {:error, %Ash.Error.Invalid{errors: errors}} = result # The error should indicate email is already taken assert Enum.any?(errors, fn err -> (err.__struct__ == Ash.Error.Changes.InvalidAttribute and err.field == :email) or err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired end) end @tag :test_proposal test "existing OIDC user email remains unchanged when oidc_id matches" do user = create_test_user(%{ email: "oidc@example.com", oidc_id: "oidc_stable_789" }) # Same OIDC ID, same email - should just sign in user_info = %{ "sub" => "oidc_stable_789", "preferred_username" => "oidc@example.com" } # This should work via upsert {:ok, updated_user} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert updated_user.id == user.id assert updated_user.oidc_id == "oidc_stable_789" assert to_string(updated_user.email) == "oidc@example.com" end end describe "Email update during OIDC linking" do @tag :test_proposal test "linking OIDC to password account updates email if different in OIDC" do # Password user with old email user = create_test_user(%{ email: "oldemail@example.com", password: "password123", oidc_id: nil }) # OIDC provider returns new email (user changed it there) user_info = %{ "sub" => "oidc_link_999", "preferred_username" => "newemail@example.com" } # After password verification, link and update email {:ok, updated_user} = Mv.Accounts.User |> Ash.Query.filter(id == ^user.id) |> Ash.read_one!() |> Ash.Changeset.for_update(:link_oidc_id, %{ oidc_id: user_info["sub"], oidc_user_info: user_info }) |> Ash.update() assert updated_user.oidc_id == "oidc_link_999" assert to_string(updated_user.email) == "newemail@example.com" end @tag :test_proposal test "email change during linking triggers member email sync" do # Create member member = Ash.Seed.seed!(Mv.Membership.Member, %{ email: "member@example.com", first_name: "Test", last_name: "User" }) # Create user linked to member user = Ash.Seed.seed!(Mv.Accounts.User, %{ email: "member@example.com", hashed_password: "dummy_hash", oidc_id: nil, member_id: member.id }) # Link OIDC with new email user_info = %{ "sub" => "oidc_sync_777", "preferred_username" => "newemail@example.com" } {:ok, updated_user} = Mv.Accounts.User |> Ash.Query.filter(id == ^user.id) |> Ash.read_one!() |> Ash.Changeset.for_update(:link_oidc_id, %{ oidc_id: user_info["sub"], oidc_user_info: user_info }) |> Ash.update() # Verify user email changed assert to_string(updated_user.email) == "newemail@example.com" # Verify member email was synced {:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id) assert to_string(updated_member.email) == "newemail@example.com" end end describe "Edge cases" do @tag :test_proposal test "user with empty string oidc_id is treated as password-only user" do _user = create_test_user(%{ email: "empty@example.com", password: "password123", oidc_id: "" }) # Try OIDC registration with same email user_info = %{ "sub" => "oidc_new_111", "preferred_username" => "empty@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Should trigger PasswordVerificationRequired (empty string = no OIDC) assert {:error, %Ash.Error.Invalid{errors: errors}} = result password_error = Enum.find(errors, fn err -> err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired end) assert password_error != nil end @tag :test_proposal test "cannot link same oidc_id to multiple users" do # User 1 with OIDC _user1 = create_test_user(%{ email: "user1@example.com", oidc_id: "shared_oidc_333" }) # Try to create user 2 with same OIDC ID using raw Ash.Changeset # (create_test_user uses Ash.Seed which does upsert) result = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "user2@example.com" }) |> Ash.Changeset.force_change_attribute(:oidc_id, "shared_oidc_333") |> Ash.create() # Should fail due to unique constraint on oidc_id assert match?({:error, %Ash.Error.Invalid{}}, result) {:error, error} = result # Verify the error is about oidc_id uniqueness assert Enum.any?(error.errors, fn err -> match?(%Ash.Error.Changes.InvalidAttribute{field: :oidc_id}, err) end) end end describe "OIDC login with passwordless user - Requires Linking Flow" do test "user without password and without oidc_id triggers PasswordVerificationRequired" 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) # OIDC registration should trigger linking flow (not automatic) user_info = %{ "sub" => "auto_link_oidc_123", "preferred_username" => "invited@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should fail with PasswordVerificationRequired # The LinkOidcAccountLive will auto-link without password prompt 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 test "user without password but WITH password later requires verification" do # Create user without password first {:ok, user} = Mv.Accounts.User |> Ash.Changeset.for_create(:create_user, %{ email: "added-password@example.com" }) |> Ash.create() # User sets password later (using admin action) {:ok, user_with_password} = user |> Ash.Changeset.for_update(:admin_set_password, %{ password: "newpassword123" }) |> Ash.update() assert not is_nil(user_with_password.hashed_password) # Now OIDC login should require password verification user_info = %{ "sub" => "needs_verification", "preferred_username" => "added-password@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should fail with PasswordVerificationRequired 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 describe "OIDC login with different oidc_id - Hard Error" do test "user with different oidc_id cannot be linked (hard error)" 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() assert existing_user.oidc_id == "original_oidc_999" # 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 (not PasswordVerificationRequired) 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 be a validation error about email 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 "cannot link different oidc_id even with password verification" do # Create user with password AND existing OIDC ID existing_user = create_test_user(%{ email: "password-and-oidc@example.com", password: "mypassword123", oidc_id: "first_oidc_111" }) assert existing_user.oidc_id == "first_oidc_111" assert not is_nil(existing_user.hashed_password) # Try to register with different OIDC ID user_info = %{ "sub" => "second_oidc_222", "preferred_username" => "password-and-oidc@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{"access_token" => "test_token"} }) # Should fail - cannot link different OIDC ID assert {:error, %Ash.Error.Invalid{}} = result {:error, error} = result # Should be a hard error, not password verification refute Enum.any?(error.errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) end end end