defmodule MvWeb.OidcE2EFlowTest do @moduledoc """ End-to-end tests for OIDC authentication flows. These tests simulate the complete user journey through OIDC authentication, including account linking scenarios. """ use MvWeb.ConnCase, async: true require Ash.Query describe "E2E: New OIDC user registration" do test "new user can register via OIDC", %{conn: _conn} do # Simulate OIDC callback for brand new user user_info = %{ "sub" => "new_oidc_user_123", "preferred_username" => "newuser@example.com" } # Call register action result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert {:ok, new_user} = result assert to_string(new_user.email) == "newuser@example.com" assert new_user.oidc_id == "new_oidc_user_123" assert is_nil(new_user.hashed_password) # Verify user can be found by oidc_id {:ok, [found_user]} = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert found_user.id == new_user.id end end describe "E2E: Existing OIDC user sign-in" do test "existing OIDC user can sign in and email updates", %{conn: _conn} do # Create OIDC user user = create_test_user(%{ email: "oldmail@example.com", oidc_id: "oidc_existing_999" }) # User changed email at OIDC provider updated_user_info = %{ "sub" => "oidc_existing_999", "preferred_username" => "newmail@example.com" } # Register (upsert) with new email {:ok, updated_user} = Mv.Accounts.create_register_with_rauthy(%{ user_info: updated_user_info, oauth_tokens: %{} }) # Same user, updated email assert updated_user.id == user.id assert to_string(updated_user.email) == "newmail@example.com" assert updated_user.oidc_id == "oidc_existing_999" end end describe "E2E: OIDC with existing password account (Email Collision)" do test "OIDC registration with password account email triggers PasswordVerificationRequired", %{conn: _conn} do # Step 1: Create a password-only user password_user = create_test_user(%{ email: "collision@example.com", password: "mypassword123", oidc_id: nil }) # Step 2: Try to register via OIDC with same email user_info = %{ "sub" => "oidc_new_777", "preferred_username" => "collision@example.com" } result = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Step 3: Should fail with PasswordVerificationRequired assert {:error, %Ash.Error.Invalid{errors: errors}} = result password_error = Enum.find(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) assert password_error != nil assert password_error.user_id == password_user.id assert password_error.oidc_user_info["sub"] == "oidc_new_777" assert password_error.oidc_user_info["preferred_username"] == "collision@example.com" end test "full E2E flow: OIDC collision -> password verification -> account linked", %{conn: _conn} do # Step 1: Create password user password_user = create_test_user(%{ email: "full@example.com", password: "testpass123", oidc_id: nil }) # Step 2: OIDC registration triggers error user_info = %{ "sub" => "oidc_link_888", "preferred_username" => "full@example.com" } {:error, %Ash.Error.Invalid{errors: errors}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Extract the error password_error = Enum.find(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) assert password_error != nil # Step 3: User verifies password (this would happen in LiveView) # Here we simulate successful password verification # Step 4: Link OIDC account after verification {:ok, linked_user} = Mv.Accounts.User |> Ash.Query.filter(id == ^password_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 account is now linked assert linked_user.id == password_user.id assert linked_user.oidc_id == "oidc_link_888" assert to_string(linked_user.email) == "full@example.com" # Password should still exist assert linked_user.hashed_password == password_user.hashed_password # Step 5: User can now sign in via OIDC {:ok, [signed_in_user]} = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert signed_in_user.id == password_user.id assert signed_in_user.oidc_id == "oidc_link_888" end test "E2E: OIDC collision with different email at provider updates email after linking", %{conn: _conn} do # Password user with old email password_user = create_test_user(%{ email: "old@example.com", password: "pass123", oidc_id: nil }) # OIDC provider has new email user_info = %{ "sub" => "oidc_new_email_555", "preferred_username" => "old@example.com" } # Collision detected {:error, %Ash.Error.Invalid{}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # After password verification, link with OIDC info that has NEW email updated_user_info = %{ "sub" => "oidc_new_email_555", "preferred_username" => "new@example.com" } {:ok, linked_user} = Mv.Accounts.User |> Ash.Query.filter(id == ^password_user.id) |> Ash.read_one!() |> Ash.Changeset.for_update(:link_oidc_id, %{ oidc_id: updated_user_info["sub"], oidc_user_info: updated_user_info }) |> Ash.update() # Email should be updated to match OIDC provider assert to_string(linked_user.email) == "new@example.com" assert linked_user.oidc_id == "oidc_new_email_555" end end describe "E2E: OIDC with linked member" do test "E2E: email sync to member when linking OIDC to password account", %{conn: _conn} do # Create member member = Ash.Seed.seed!(Mv.Membership.Member, %{ email: "member@example.com", first_name: "Test", last_name: "User" }) # Create password user linked to member password_user = Ash.Seed.seed!(Mv.Accounts.User, %{ email: "member@example.com", hashed_password: "dummy_hash", oidc_id: nil, member_id: member.id }) # OIDC registration with same email user_info = %{ "sub" => "oidc_member_333", "preferred_username" => "member@example.com" } # Collision detected {:error, %Ash.Error.Invalid{}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # After password verification, link OIDC with NEW email updated_user_info = %{ "sub" => "oidc_member_333", "preferred_username" => "newmember@example.com" } {:ok, linked_user} = Mv.Accounts.User |> Ash.Query.filter(id == ^password_user.id) |> Ash.read_one!() |> Ash.Changeset.for_update(:link_oidc_id, %{ oidc_id: updated_user_info["sub"], oidc_user_info: updated_user_info }) |> Ash.update() # User email updated assert to_string(linked_user.email) == "newmember@example.com" # Member email should be synced {:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id) assert to_string(updated_member.email) == "newmember@example.com" end end describe "E2E: Security scenarios" do test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: _conn} do # Create password user _password_user = create_test_user(%{ email: "secure@example.com", password: "securepass123", oidc_id: nil }) # Attacker tries to sign in via OIDC with same email user_info = %{ "sub" => "attacker_oidc_666", "preferred_username" => "secure@example.com" } # Sign-in should fail (no matching oidc_id) result = Mv.Accounts.read_sign_in_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) case result do {:ok, []} -> :ok {:error, %Ash.Error.Forbidden{}} -> :ok other -> flunk("Expected no access, got: #{inspect(other)}") end # Registration should trigger password requirement {:error, %Ash.Error.Invalid{errors: errors}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert Enum.any?(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) end test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: _conn} do # User linked to OIDC provider A _user = create_test_user(%{ email: "linked@example.com", oidc_id: "provider_a_123" }) # Attacker tries to register with OIDC provider B using same email user_info = %{ "sub" => "provider_b_456", "preferred_username" => "linked@example.com" } # Should trigger hard error (not PasswordVerificationRequired) {:error, %Ash.Error.Invalid{errors: errors}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Should have hard error about "already linked to a different OIDC account" assert Enum.any?(errors, fn %Ash.Error.Changes.InvalidAttribute{message: msg} -> String.contains?(msg, "already linked to a different OIDC account") _ -> false end) # Should NOT be PasswordVerificationRequired refute Enum.any?(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) end test "E2E: empty string oidc_id is treated as password-only account", %{conn: _conn} do # User with empty oidc_id _password_user = create_test_user(%{ email: "empty@example.com", password: "pass123", oidc_id: "" }) # Try OIDC registration user_info = %{ "sub" => "oidc_new_222", "preferred_username" => "empty@example.com" } {:error, %Ash.Error.Invalid{errors: errors}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) # Should require password (empty string = no OIDC) assert Enum.any?(errors, fn err -> match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) end) end end describe "E2E: Error scenarios" do test "E2E: OIDC registration without oidc_id fails", %{conn: _conn} do user_info = %{ "preferred_username" => "noid@example.com" } {:error, %Ash.Error.Invalid{errors: errors}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert Enum.any?(errors, fn err -> match?(%Ash.Error.Changes.InvalidChanges{}, err) end) end test "E2E: OIDC registration without email fails", %{conn: _conn} do user_info = %{ "sub" => "noemail_123" } {:error, %Ash.Error.Invalid{errors: errors}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) assert Enum.any?(errors, fn err -> match?(%Ash.Error.Changes.Required{field: :email}, err) end) end end end