From fa40a421566ba72e031f72174884199032ebc247 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 11:25:14 +0100 Subject: [PATCH] add UI e2e tests for account linking --- .../mv_web/controllers/oidc_e2e_flow_test.exs | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 test/mv_web/controllers/oidc_e2e_flow_test.exs diff --git a/test/mv_web/controllers/oidc_e2e_flow_test.exs b/test/mv_web/controllers/oidc_e2e_flow_test.exs new file mode 100644 index 0000000..c992d2f --- /dev/null +++ b/test/mv_web/controllers/oidc_e2e_flow_test.exs @@ -0,0 +1,409 @@ +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 password requirement (different oidc_id) + {: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 -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + + assert password_error != nil + assert password_error.user_id == user.id + 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