refactor and docs
This commit is contained in:
parent
4ba03821a2
commit
5ce220862f
13 changed files with 1321 additions and 174 deletions
|
|
@ -9,7 +9,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
require Ash.Query
|
||||
|
||||
describe "E2E: New OIDC user registration" do
|
||||
test "new user can register via OIDC", %{conn: conn} 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",
|
||||
|
|
@ -40,7 +40,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
end
|
||||
|
||||
describe "E2E: Existing OIDC user sign-in" do
|
||||
test "existing OIDC user can sign in and email updates", %{conn: conn} do
|
||||
test "existing OIDC user can sign in and email updates", %{conn: _conn} do
|
||||
# Create OIDC user
|
||||
user =
|
||||
create_test_user(%{
|
||||
|
|
@ -70,7 +70,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
|
||||
describe "E2E: OIDC with existing password account (Email Collision)" do
|
||||
test "OIDC registration with password account email triggers PasswordVerificationRequired",
|
||||
%{conn: conn} do
|
||||
%{conn: _conn} do
|
||||
# Step 1: Create a password-only user
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
|
|
@ -106,7 +106,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
end
|
||||
|
||||
test "full E2E flow: OIDC collision -> password verification -> account linked",
|
||||
%{conn: conn} do
|
||||
%{conn: _conn} do
|
||||
# Step 1: Create password user
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
|
|
@ -168,7 +168,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
end
|
||||
|
||||
test "E2E: OIDC collision with different email at provider updates email after linking",
|
||||
%{conn: conn} do
|
||||
%{conn: _conn} do
|
||||
# Password user with old email
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
|
|
@ -213,7 +213,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
end
|
||||
|
||||
describe "E2E: OIDC with linked member" do
|
||||
test "E2E: email sync to member when linking OIDC to password account", %{conn: conn} 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, %{
|
||||
|
|
@ -270,7 +270,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
end
|
||||
|
||||
describe "E2E: Security scenarios" do
|
||||
test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: conn} do
|
||||
test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: _conn} do
|
||||
# Create password user
|
||||
_password_user =
|
||||
create_test_user(%{
|
||||
|
|
@ -315,9 +315,9 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
end)
|
||||
end
|
||||
|
||||
test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: conn} do
|
||||
test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: _conn} do
|
||||
# User linked to OIDC provider A
|
||||
user =
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "linked@example.com",
|
||||
oidc_id: "provider_a_123"
|
||||
|
|
@ -329,25 +329,31 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
"preferred_username" => "linked@example.com"
|
||||
}
|
||||
|
||||
# Should trigger password requirement (different oidc_id)
|
||||
# Should trigger hard error (not PasswordVerificationRequired)
|
||||
{: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)
|
||||
# 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")
|
||||
|
||||
assert password_error != nil
|
||||
assert password_error.user_id == user.id
|
||||
_ ->
|
||||
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
|
||||
test "E2E: empty string oidc_id is treated as password-only account", %{conn: _conn} do
|
||||
# User with empty oidc_id
|
||||
password_user =
|
||||
_password_user =
|
||||
create_test_user(%{
|
||||
email: "empty@example.com",
|
||||
password: "pass123",
|
||||
|
|
@ -374,7 +380,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
end
|
||||
|
||||
describe "E2E: Error scenarios" do
|
||||
test "E2E: OIDC registration without oidc_id fails", %{conn: conn} do
|
||||
test "E2E: OIDC registration without oidc_id fails", %{conn: _conn} do
|
||||
user_info = %{
|
||||
"preferred_username" => "noid@example.com"
|
||||
}
|
||||
|
|
@ -390,7 +396,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
end)
|
||||
end
|
||||
|
||||
test "E2E: OIDC registration without email fails", %{conn: conn} do
|
||||
test "E2E: OIDC registration without email fails", %{conn: _conn} do
|
||||
user_info = %{
|
||||
"sub" => "noemail_123"
|
||||
}
|
||||
|
|
|
|||
271
test/mv_web/controllers/oidc_email_update_test.exs
Normal file
271
test/mv_web/controllers/oidc_email_update_test.exs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
defmodule MvWeb.OidcEmailUpdateTest do
|
||||
@moduledoc """
|
||||
Tests for OIDC email updates - when an existing OIDC user changes their email
|
||||
in the OIDC provider and logs in again.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
describe "OIDC user updates email to available email" do
|
||||
test "should succeed and update email" do
|
||||
# Create OIDC user
|
||||
{:ok, oidc_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "original@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_123")
|
||||
|> Ash.create()
|
||||
|
||||
# User logs in via OIDC with NEW email
|
||||
user_info = %{
|
||||
"sub" => "oidc_123",
|
||||
"preferred_username" => "newemail@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should succeed and email should be updated
|
||||
assert {:ok, updated_user} = result
|
||||
assert updated_user.id == oidc_user.id
|
||||
assert to_string(updated_user.email) == "newemail@example.com"
|
||||
assert updated_user.oidc_id == "oidc_123"
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC user updates email to email of passwordless user" do
|
||||
test "should fail with clear error message" do
|
||||
# Create OIDC user
|
||||
{:ok, _oidc_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "oidcuser@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_456")
|
||||
|> Ash.create()
|
||||
|
||||
# Create passwordless user with target email
|
||||
{:ok, _passwordless_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "taken@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# OIDC user tries to update email to taken email
|
||||
user_info = %{
|
||||
"sub" => "oidc_456",
|
||||
"preferred_username" => "taken@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with email update conflict error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Should contain error about email being registered to another account
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "Cannot update email to") and
|
||||
String.contains?(message, "already registered to another account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|
||||
# Should NOT contain PasswordVerificationRequired
|
||||
refute Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC user updates email to email of password-protected user" do
|
||||
test "should fail with clear error message" do
|
||||
# Create OIDC user
|
||||
{:ok, _oidc_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "oidcuser2@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_789")
|
||||
|> Ash.create()
|
||||
|
||||
# Create password user with target email (explicitly NO oidc_id)
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
email: "passworduser@example.com",
|
||||
password: "securepass123"
|
||||
})
|
||||
|
||||
# Ensure it's a password-only user
|
||||
{:ok, password_user} = Ash.reload(password_user)
|
||||
assert not is_nil(password_user.hashed_password)
|
||||
# Force oidc_id to be nil to avoid any confusion
|
||||
{:ok, password_user} =
|
||||
password_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, nil)
|
||||
|> Ash.update()
|
||||
|
||||
assert is_nil(password_user.oidc_id)
|
||||
|
||||
# OIDC user tries to update email to password user's email
|
||||
user_info = %{
|
||||
"sub" => "oidc_789",
|
||||
"preferred_username" => "passworduser@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with email update conflict error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Should contain error about email being registered to another account
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "Cannot update email to") and
|
||||
String.contains?(message, "already registered to another account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|
||||
# Should NOT contain PasswordVerificationRequired
|
||||
refute Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC user updates email to email of different OIDC user" do
|
||||
test "should fail with clear error message about different OIDC account" do
|
||||
# Create first OIDC user
|
||||
{:ok, _oidc_user1} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "oidcuser1@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_aaa")
|
||||
|> Ash.create()
|
||||
|
||||
# Create second OIDC user with target email
|
||||
{:ok, _oidc_user2} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "oidcuser2@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_bbb")
|
||||
|> Ash.create()
|
||||
|
||||
# First OIDC user tries to update email to second user's email
|
||||
user_info = %{
|
||||
"sub" => "oidc_aaa",
|
||||
"preferred_username" => "oidcuser2@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with "already linked to different OIDC account" error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Should contain error about different OIDC account
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "already linked to a different OIDC account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|
||||
# Should NOT contain PasswordVerificationRequired
|
||||
refute Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "New OIDC user registration scenarios (for comparison)" do
|
||||
test "new OIDC user with email of passwordless user triggers linking flow" do
|
||||
# Create passwordless user
|
||||
{:ok, passwordless_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "passwordless@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# New OIDC user tries to register
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_999",
|
||||
"preferred_username" => "passwordless@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should trigger PasswordVerificationRequired (linking flow)
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
assert Enum.any?(errors, fn
|
||||
%Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} ->
|
||||
user_id == passwordless_user.id
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
end
|
||||
|
||||
test "new OIDC user with email of existing OIDC user shows hard error" do
|
||||
# Create existing OIDC user
|
||||
{:ok, _existing_oidc_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "existing@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_existing")
|
||||
|> Ash.create()
|
||||
|
||||
# New OIDC user tries to register with same email
|
||||
user_info = %{
|
||||
"sub" => "oidc_new",
|
||||
"preferred_username" => "existing@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with "already linked to different OIDC account" error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "already linked to a different OIDC account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -175,9 +175,9 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
end
|
||||
|
||||
describe "OIDC error and edge case scenarios" do
|
||||
test "OIDC registration with conflicting email and OIDC ID shows error" do
|
||||
test "OIDC registration with conflicting email and OIDC ID shows hard error" do
|
||||
# Create user with email and OIDC ID
|
||||
existing_user =
|
||||
_existing_user =
|
||||
create_test_user(%{
|
||||
email: "conflict@example.com",
|
||||
oidc_id: "oidc_conflict_1"
|
||||
|
|
@ -195,19 +195,24 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Should fail with PasswordVerificationRequired (account conflict)
|
||||
# Should fail with hard error (not PasswordVerificationRequired)
|
||||
# This prevents someone with OIDC provider B from taking over an account
|
||||
# that's already linked to OIDC provider A
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Should contain PasswordVerificationRequired error
|
||||
# Should contain error about "already linked to a different OIDC account"
|
||||
assert Enum.any?(errors, fn
|
||||
%Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} ->
|
||||
user_id == existing_user.id
|
||||
%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 "OIDC registration with missing sub and id should fail" do
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
|||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "user2@example.com"
|
||||
})
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, "shared_oidc_333")
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "shared_oidc_333")
|
||||
|> Ash.create()
|
||||
|
||||
# Should fail due to unique constraint on oidc_id
|
||||
|
|
@ -335,4 +335,162 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|
|||
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
|
||||
|
|
|
|||
210
test/mv_web/controllers/oidc_passwordless_linking_test.exs
Normal file
210
test/mv_web/controllers/oidc_passwordless_linking_test.exs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||
@moduledoc """
|
||||
Tests for OIDC account linking with passwordless users.
|
||||
|
||||
These tests verify the behavior when a passwordless user
|
||||
(e.g., invited user, user created by admin) attempts to log in via OIDC.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
describe "Passwordless user - Automatic linking via special action" do
|
||||
test "passwordless user can be linked via link_passwordless_oidc action" 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)
|
||||
|
||||
# Link via special action (simulating what happens after first OIDC attempt)
|
||||
{:ok, linked_user} =
|
||||
existing_user
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: "auto_link_oidc_123",
|
||||
oidc_user_info: %{
|
||||
"sub" => "auto_link_oidc_123",
|
||||
"preferred_username" => "invited@example.com"
|
||||
}
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# User should now have oidc_id linked
|
||||
assert linked_user.oidc_id == "auto_link_oidc_123"
|
||||
assert linked_user.id == existing_user.id
|
||||
|
||||
# Now OIDC sign-in should work
|
||||
result =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.for_read(:sign_in_with_rauthy, %{
|
||||
user_info: %{
|
||||
"sub" => "auto_link_oidc_123",
|
||||
"preferred_username" => "invited@example.com"
|
||||
},
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|> Ash.read_one()
|
||||
|
||||
assert {:ok, signed_in_user} = result
|
||||
assert signed_in_user.id == existing_user.id
|
||||
end
|
||||
|
||||
test "passwordless user triggers PasswordVerificationRequired for linking flow" do
|
||||
# Create passwordless user
|
||||
{:ok, existing_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "passwordless@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert is_nil(existing_user.hashed_password)
|
||||
assert is_nil(existing_user.oidc_id)
|
||||
|
||||
# Try OIDC registration - should trigger PasswordVerificationRequired
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_456",
|
||||
"preferred_username" => "passwordless@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with PasswordVerificationRequired
|
||||
# LinkOidcAccountLive will auto-link without password prompt
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
assert Enum.any?(error.errors, fn err ->
|
||||
case err do
|
||||
%Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} ->
|
||||
user_id == existing_user.id
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "User with different OIDC ID - Hard Error" do
|
||||
test "user with different oidc_id gets hard error, not password verification" 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()
|
||||
|
||||
# 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
|
||||
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 have error message about 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 "passwordless user with different oidc_id also gets hard error" do
|
||||
# Create passwordless user with OIDC ID
|
||||
{:ok, existing_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "passwordless-linked@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "first_oidc_777")
|
||||
|> Ash.create()
|
||||
|
||||
assert is_nil(existing_user.hashed_password)
|
||||
assert existing_user.oidc_id == "first_oidc_777"
|
||||
|
||||
# Try to register with different OIDC ID
|
||||
user_info = %{
|
||||
"sub" => "second_oidc_666",
|
||||
"preferred_username" => "passwordless-linked@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should be hard error, not PasswordVerificationRequired
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
refute Enum.any?(error.errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Password user - Requires verification (existing behavior)" do
|
||||
test "password user without oidc_id requires password verification" do
|
||||
# Create password user
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
email: "password@example.com",
|
||||
password: "securepass123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
assert not is_nil(password_user.hashed_password)
|
||||
assert is_nil(password_user.oidc_id)
|
||||
|
||||
# Try OIDC registration
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_999",
|
||||
"preferred_username" => "password@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should require password verification
|
||||
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
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue