defmodule Mv.Accounts.UserAuthenticationTest do @moduledoc """ Tests for user authentication and identification mechanisms. This test suite verifies that: - Password login correctly identifies users via email - OIDC login correctly identifies users via oidc_id - Session identifiers work as expected for both authentication methods """ use MvWeb.ConnCase, async: true require Ash.Query describe "Password authentication user identification" do @tag :test_proposal test "password login uses email as identifier" do # Create a user with password authentication (no oidc_id) user = create_test_user(%{ email: "password.user@example.com", password: "securepassword123", oidc_id: nil }) # Verify that the user can be found by email email_to_find = to_string(user.email) {:ok, users} = Mv.Accounts.User |> Ash.Query.filter(email == ^email_to_find) |> Ash.read() assert length(users) == 1 found_user = List.first(users) assert found_user.id == user.id assert to_string(found_user.email) == "password.user@example.com" assert is_nil(found_user.oidc_id) end @tag :test_proposal test "password authentication uses email as identity_field" do # Verify the configuration: password strategy should use email as identity_field # This test checks the AshAuthentication configuration strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User) password_strategy = Enum.find(strategies, fn s -> s.name == :password end) assert password_strategy != nil assert password_strategy.identity_field == :email end @tag :test_proposal test "multiple users can exist with different emails" do user1 = create_test_user(%{ email: "user1@example.com", password: "password123", oidc_id: nil }) user2 = create_test_user(%{ email: "user2@example.com", password: "password456", oidc_id: nil }) assert user1.id != user2.id assert to_string(user1.email) != to_string(user2.email) end @tag :test_proposal test "users with same password but different emails are separate accounts" do same_password = "shared_password_123" user1 = create_test_user(%{ email: "alice@example.com", password: same_password, oidc_id: nil }) user2 = create_test_user(%{ email: "bob@example.com", password: same_password, oidc_id: nil }) # Different users despite same password assert user1.id != user2.id # Both passwords should hash to different values (bcrypt uses salt) assert user1.hashed_password != user2.hashed_password end end describe "OIDC authentication user identification" do @tag :test_proposal test "OIDC login with matching oidc_id finds correct user" do # Create user with OIDC authentication user = create_test_user(%{ email: "oidc.user@example.com", oidc_id: "oidc_identifier_12345" }) # Simulate OIDC callback user_info = %{ "sub" => "oidc_identifier_12345", "preferred_username" => "oidc.user@example.com" } # Use sign_in_with_rauthy to find user by oidc_id # Note: This test will FAIL until we implement the security fix # that changes the filter from email to oidc_id result = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) case result do {:ok, [found_user]} -> assert found_user.id == user.id assert found_user.oidc_id == "oidc_identifier_12345" {:ok, []} -> flunk("User should be found by oidc_id") {:error, error} -> flunk("Unexpected error: #{inspect(error)}") end end @tag :test_proposal test "OIDC login creates new user when both email and oidc_id are new" do # Completely new user from OIDC provider user_info = %{ "sub" => "brand_new_oidc_789", "preferred_username" => "newuser@example.com" } # Should create via register_with_rauthy {:ok, new_user} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert to_string(new_user.email) == "newuser@example.com" assert new_user.oidc_id == "brand_new_oidc_789" assert is_nil(new_user.hashed_password) end @tag :test_proposal test "OIDC user can be uniquely identified by oidc_id" do user1 = create_test_user(%{ email: "user1@example.com", oidc_id: "oidc_unique_1" }) user2 = create_test_user(%{ email: "user2@example.com", oidc_id: "oidc_unique_2" }) # Find by oidc_id {:ok, users1} = Mv.Accounts.User |> Ash.Query.filter(oidc_id == "oidc_unique_1") |> Ash.read() {:ok, users2} = Mv.Accounts.User |> Ash.Query.filter(oidc_id == "oidc_unique_2") |> Ash.read() assert length(users1) == 1 assert length(users2) == 1 assert List.first(users1).id == user1.id assert List.first(users2).id == user2.id end end describe "Mixed authentication scenarios" do @tag :test_proposal test "user with oidc_id cannot be found by email-only query in sign_in_with_rauthy" do # This test verifies the security fix: sign_in_with_rauthy should NOT # match users by email, only by oidc_id _user = create_test_user(%{ email: "secure@example.com", oidc_id: "secure_oidc_999" }) # Try to sign in with DIFFERENT oidc_id but SAME email user_info = %{ # Different oidc_id! "sub" => "attacker_oidc_888", # Same email "preferred_username" => "secure@example.com" } # Should NOT find the user (security requirement) result = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Either returns empty list OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok other -> flunk("sign_in_with_rauthy should not match by email alone, got: #{inspect(other)}") end end @tag :test_proposal test "password user (oidc_id=nil) is not found by sign_in_with_rauthy" do # Create a password-only user _user = create_test_user(%{ email: "password.only@example.com", password: "securepass123", oidc_id: nil }) # Try OIDC sign-in with this email user_info = %{ "sub" => "new_oidc_777", "preferred_username" => "password.only@example.com" } # Should NOT find the user because oidc_id is nil result = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Either returns empty list OR authentication error - both mean "user not found" case result do {:ok, []} -> :ok {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> :ok other -> flunk( "Password-only user should not be found by sign_in_with_rauthy, got: #{inspect(other)}" ) end end end end