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.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 end