Add actor parameter to all tests requiring authorization

This commit adds actor: system_actor to all Ash operations in tests that
require authorization.
This commit is contained in:
Moritz 2026-01-23 20:00:24 +01:00
parent 686f69c9e9
commit 0f48a9b15a
Signed by: moritz
GPG key ID: 1020A035E5DD0824
75 changed files with 4686 additions and 2859 deletions

View file

@ -8,8 +8,13 @@ defmodule MvWeb.OidcE2EFlowTest do
use MvWeb.ConnCase, async: true
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
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, actor: actor} do
# Simulate OIDC callback for brand new user
user_info = %{
"sub" => "new_oidc_user_123",
@ -18,10 +23,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Call register action
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert {:ok, new_user} = result
assert to_string(new_user.email) == "newuser@example.com"
@ -30,17 +38,20 @@ defmodule MvWeb.OidcE2EFlowTest do
# Verify user can be found by oidc_id
{:ok, [found_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
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
test "existing OIDC user can sign in and email updates", %{conn: _conn, actor: actor} do
# Create OIDC user
user =
create_test_user(%{
@ -56,10 +67,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Register (upsert) with new email
{:ok, updated_user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: updated_user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: updated_user_info,
oauth_tokens: %{}
},
actor: actor
)
# Same user, updated email
assert updated_user.id == user.id
@ -70,7 +84,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, actor: actor} do
# Step 1: Create a password-only user
password_user =
create_test_user(%{
@ -86,10 +100,13 @@ defmodule MvWeb.OidcE2EFlowTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Step 3: Should fail with PasswordVerificationRequired
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -106,7 +123,7 @@ defmodule MvWeb.OidcE2EFlowTest do
end
test "full E2E flow: OIDC collision -> password verification -> account linked",
%{conn: _conn} do
%{conn: _conn, actor: actor} do
# Step 1: Create password user
password_user =
create_test_user(%{
@ -122,10 +139,13 @@ defmodule MvWeb.OidcE2EFlowTest do
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Extract the error
password_error =
@ -142,12 +162,12 @@ defmodule MvWeb.OidcE2EFlowTest do
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: user_info["sub"],
oidc_user_info: user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
# Verify account is now linked
assert linked_user.id == password_user.id
@ -158,17 +178,20 @@ defmodule MvWeb.OidcE2EFlowTest do
# 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: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
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
%{conn: _conn, actor: actor} do
# Password user with old email
password_user =
create_test_user(%{
@ -199,12 +222,12 @@ defmodule MvWeb.OidcE2EFlowTest do
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: updated_user_info["sub"],
oidc_user_info: updated_user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
# Email should be updated to match OIDC provider
assert to_string(linked_user.email) == "new@example.com"
@ -213,7 +236,10 @@ 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,
actor: actor
} do
# Create member
member =
Ash.Seed.seed!(Mv.Membership.Member, %{
@ -239,10 +265,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Collision detected
{:error, %Ash.Error.Invalid{}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# After password verification, link OIDC with NEW email
updated_user_info = %{
@ -253,24 +282,27 @@ defmodule MvWeb.OidcE2EFlowTest do
{:ok, linked_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^password_user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: updated_user_info["sub"],
oidc_user_info: updated_user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
# 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)
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
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
test "E2E: password-only user cannot be accessed via OIDC without password", %{
conn: _conn,
actor: actor
} do
# Create password user
_password_user =
create_test_user(%{
@ -287,10 +319,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Sign-in should fail (no matching oidc_id)
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
case result do
{:ok, []} ->
@ -305,17 +340,23 @@ defmodule MvWeb.OidcE2EFlowTest do
# Registration should trigger password requirement
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
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
test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{
conn: _conn,
actor: actor
} do
# User linked to OIDC provider A
_user =
create_test_user(%{
@ -331,10 +372,13 @@ defmodule MvWeb.OidcE2EFlowTest do
# Should trigger hard error (not PasswordVerificationRequired)
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should have hard error about "already linked to a different OIDC account"
assert Enum.any?(errors, fn
@ -351,7 +395,10 @@ defmodule MvWeb.OidcE2EFlowTest do
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,
actor: actor
} do
# User with empty oidc_id
_password_user =
create_test_user(%{
@ -367,10 +414,13 @@ defmodule MvWeb.OidcE2EFlowTest do
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should require password (empty string = no OIDC)
assert Enum.any?(errors, fn err ->
@ -380,32 +430,38 @@ 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, actor: actor} 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: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert Enum.any?(errors, fn err ->
match?(%Ash.Error.Changes.InvalidChanges{}, err)
end)
end
test "E2E: OIDC registration without email fails", %{conn: _conn} do
test "E2E: OIDC registration without email fails", %{conn: _conn, actor: actor} do
user_info = %{
"sub" => "noemail_123"
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert Enum.any?(errors, fn err ->
match?(%Ash.Error.Changes.Required{field: :email}, err)

View file

@ -5,8 +5,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
"""
use MvWeb.ConnCase, async: true
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "OIDC user updates email to available email" do
test "should succeed and update email" do
test "should succeed and update email", %{actor: actor} do
# Create OIDC user
{:ok, oidc_user} =
Mv.Accounts.User
@ -14,7 +19,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "original@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_123")
|> Ash.create()
|> Ash.create(actor: actor)
# User logs in via OIDC with NEW email
user_info = %{
@ -23,10 +28,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should succeed and email should be updated
assert {:ok, updated_user} = result
@ -37,7 +45,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
end
describe "OIDC user updates email to email of passwordless user" do
test "should fail with clear error message" do
test "should fail with clear error message", %{actor: actor} do
# Create OIDC user
{:ok, _oidc_user} =
Mv.Accounts.User
@ -45,7 +53,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "oidcuser@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_456")
|> Ash.create()
|> Ash.create(actor: actor)
# Create passwordless user with target email
{:ok, _passwordless_user} =
@ -53,7 +61,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
|> Ash.Changeset.for_create(:create_user, %{
email: "taken@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
# OIDC user tries to update email to taken email
user_info = %{
@ -62,10 +70,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with email update conflict error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -88,7 +99,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
end
describe "OIDC user updates email to email of password-protected user" do
test "should fail with clear error message" do
test "should fail with clear error message", %{actor: actor} do
# Create OIDC user
{:ok, _oidc_user} =
Mv.Accounts.User
@ -96,7 +107,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "oidcuser2@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_789")
|> Ash.create()
|> Ash.create(actor: actor)
# Create password user with target email (explicitly NO oidc_id)
password_user =
@ -106,14 +117,14 @@ defmodule MvWeb.OidcEmailUpdateTest do
})
# Ensure it's a password-only user
{:ok, password_user} = Ash.reload(password_user)
{:ok, password_user} = Ash.reload(password_user, actor: actor)
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()
|> Ash.update(actor: actor)
assert is_nil(password_user.oidc_id)
@ -124,10 +135,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with email update conflict error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -150,7 +164,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
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
test "should fail with clear error message about different OIDC account", %{actor: actor} do
# Create first OIDC user
{:ok, _oidc_user1} =
Mv.Accounts.User
@ -158,7 +172,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "oidcuser1@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_aaa")
|> Ash.create()
|> Ash.create(actor: actor)
# Create second OIDC user with target email
{:ok, _oidc_user2} =
@ -167,7 +181,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "oidcuser2@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_bbb")
|> Ash.create()
|> Ash.create(actor: actor)
# First OIDC user tries to update email to second user's email
user_info = %{
@ -176,10 +190,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with "already linked to different OIDC account" error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -201,14 +218,14 @@ defmodule MvWeb.OidcEmailUpdateTest do
end
describe "New OIDC user registration scenarios (for comparison)" do
test "new OIDC user with email of passwordless user triggers linking flow" do
test "new OIDC user with email of passwordless user triggers linking flow", %{actor: actor} do
# Create passwordless user
{:ok, passwordless_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "passwordless@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
# New OIDC user tries to register
user_info = %{
@ -217,10 +234,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should trigger PasswordVerificationRequired (linking flow)
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -234,7 +254,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
end)
end
test "new OIDC user with email of existing OIDC user shows hard error" do
test "new OIDC user with email of existing OIDC user shows hard error", %{actor: actor} do
# Create existing OIDC user
{:ok, _existing_oidc_user} =
Mv.Accounts.User
@ -242,7 +262,7 @@ defmodule MvWeb.OidcEmailUpdateTest do
email: "existing@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_existing")
|> Ash.create()
|> Ash.create(actor: actor)
# New OIDC user tries to register with same email
user_info = %{
@ -251,10 +271,13 @@ defmodule MvWeb.OidcEmailUpdateTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with "already linked to different OIDC account" error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result

View file

@ -4,6 +4,11 @@ defmodule MvWeb.OidcIntegrationTest do
# Test OIDC callback scenarios by directly calling the actions
# This simulates what happens during real OIDC authentication
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "OIDC sign-in scenarios" do
test "existing OIDC user with unchanged email can sign in" do
# Create user with OIDC ID
@ -20,11 +25,16 @@ defmodule MvWeb.OidcIntegrationTest do
}
# Test sign_in_with_rauthy action directly
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, [found_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert found_user.id == user.id
assert to_string(found_user.email) == "existing@example.com"
@ -39,10 +49,15 @@ defmodule MvWeb.OidcIntegrationTest do
}
# Test register_with_rauthy action
case Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
case Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
) do
{:ok, new_user} ->
assert to_string(new_user.email) == "newuser@example.com"
assert new_user.oidc_id == "brand_new_oidc_456"
@ -73,11 +88,16 @@ defmodule MvWeb.OidcIntegrationTest do
}
# Should NOT find any user (security requirement)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do
@ -107,11 +127,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "oidc.user@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, [found_user]} =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: correct_user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: correct_user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert found_user.id == user.id
@ -122,10 +147,13 @@ defmodule MvWeb.OidcIntegrationTest do
}
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: wrong_user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: wrong_user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do
@ -154,11 +182,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "empty.oidc@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.read_sign_in_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Either returns empty list OR authentication error - both mean "user not found"
case result do
@ -189,11 +222,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "conflict@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
# Should fail with hard error (not PasswordVerificationRequired)
# This prevents someone with OIDC provider B from taking over an account
@ -220,11 +258,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "nosub@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert {:error,
%Ash.Error.Invalid{
@ -239,11 +282,16 @@ defmodule MvWeb.OidcIntegrationTest do
"sub" => "noemail_oidc_123"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -264,11 +312,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "new@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert user.id == existing_user.id
assert to_string(user.email) == "new@example.com"
@ -281,11 +334,16 @@ defmodule MvWeb.OidcIntegrationTest do
"preferred_username" => "altid@example.com"
}
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: system_actor
)
assert user.oidc_id == "alt_oidc_id_123"
assert to_string(user.email) == "altid@example.com"

View file

@ -8,9 +8,15 @@ defmodule MvWeb.OidcPasswordLinkingTest do
use MvWeb.ConnCase, async: true
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
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
test "OIDC register with existing password user email fails with PasswordVerificationRequired",
%{actor: actor} do
# Create password-only user
existing_user =
create_test_user(%{
@ -26,10 +32,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should fail with PasswordVerificationRequired error
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -47,7 +56,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "PasswordVerificationRequired error contains necessary context" do
test "PasswordVerificationRequired error contains necessary context", %{actor: actor} do
existing_user =
create_test_user(%{
email: "test@example.com",
@ -61,10 +70,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
{:error, %Ash.Error.Invalid{errors: errors}} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
password_error =
Enum.find(errors, fn err ->
@ -78,7 +90,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "after successful password verification, oidc_id can be set" do
test "after successful password verification, oidc_id can be set", %{actor: actor} do
# Create password user
user =
create_test_user(%{
@ -97,12 +109,12 @@ defmodule MvWeb.OidcPasswordLinkingTest do
{:ok, updated_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: user_info["sub"],
oidc_user_info: user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
assert updated_user.id == user.id
assert updated_user.oidc_id == "linked_oidc_555"
@ -112,7 +124,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "password verification with wrong password keeps oidc_id as nil" do
test "password verification with wrong password keeps oidc_id as nil", %{actor: actor} do
# This test verifies that if password verification fails,
# the oidc_id should NOT be set
@ -131,7 +143,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
# 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)
{:ok, unchanged_user} = Ash.get(Mv.Accounts.User, user.id, actor: actor)
assert is_nil(unchanged_user.oidc_id)
assert unchanged_user.hashed_password == user.hashed_password
end
@ -139,7 +151,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
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
test "OIDC register with email of user having different oidc_id fails", %{actor: actor} do
# User already linked to OIDC provider A
_existing_user =
create_test_user(%{
@ -155,10 +167,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should fail - cannot link different OIDC account to same email
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -171,7 +186,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "existing OIDC user email remains unchanged when oidc_id matches" do
test "existing OIDC user email remains unchanged when oidc_id matches", %{actor: actor} do
user =
create_test_user(%{
email: "oidc@example.com",
@ -186,10 +201,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
# This should work via upsert
{:ok, updated_user} =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
assert updated_user.id == user.id
assert updated_user.oidc_id == "oidc_stable_789"
@ -199,7 +217,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
describe "Email update during OIDC linking" do
@tag :test_proposal
test "linking OIDC to password account updates email if different in OIDC" do
test "linking OIDC to password account updates email if different in OIDC", %{actor: actor} do
# Password user with old email
user =
create_test_user(%{
@ -218,19 +236,19 @@ defmodule MvWeb.OidcPasswordLinkingTest do
{:ok, updated_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: user_info["sub"],
oidc_user_info: user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
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
test "email change during linking triggers member email sync", %{actor: actor} do
# Create member
member =
Ash.Seed.seed!(Mv.Membership.Member, %{
@ -257,25 +275,25 @@ defmodule MvWeb.OidcPasswordLinkingTest do
{:ok, updated_user} =
Mv.Accounts.User
|> Ash.Query.filter(id == ^user.id)
|> Ash.read_one!()
|> Ash.read_one!(actor: actor)
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: user_info["sub"],
oidc_user_info: user_info
})
|> Ash.update()
|> Ash.update(actor: actor)
# 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)
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
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
test "user with empty string oidc_id is treated as password-only user", %{actor: actor} do
_user =
create_test_user(%{
email: "empty@example.com",
@ -290,10 +308,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{}
},
actor: actor
)
# Should trigger PasswordVerificationRequired (empty string = no OIDC)
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
@ -307,7 +328,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
@tag :test_proposal
test "cannot link same oidc_id to multiple users" do
test "cannot link same oidc_id to multiple users", %{actor: actor} do
# User 1 with OIDC
_user1 =
create_test_user(%{
@ -323,7 +344,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
email: "user2@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "shared_oidc_333")
|> Ash.create()
|> Ash.create(actor: actor)
# Should fail due to unique constraint on oidc_id
assert match?({:error, %Ash.Error.Invalid{}}, result)
@ -337,14 +358,16 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
describe "OIDC login with passwordless user - Requires Linking Flow" do
test "user without password and without oidc_id triggers PasswordVerificationRequired" do
test "user without password and without oidc_id triggers PasswordVerificationRequired", %{
actor: actor
} 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()
|> Ash.create(actor: actor)
# Verify user has no password and no oidc_id
assert is_nil(existing_user.hashed_password)
@ -372,14 +395,14 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end)
end
test "user without password but WITH password later requires verification" do
test "user without password but WITH password later requires verification", %{actor: actor} do
# Create user without password first
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "added-password@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
# User sets password later (using admin action)
{:ok, user_with_password} =
@ -387,7 +410,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
|> Ash.Changeset.for_update(:admin_set_password, %{
password: "newpassword123"
})
|> Ash.update()
|> Ash.update(actor: actor)
assert not is_nil(user_with_password.hashed_password)
@ -398,10 +421,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with PasswordVerificationRequired
assert {:error, %Ash.Error.Invalid{}} = result
@ -414,7 +440,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end
describe "OIDC login with different oidc_id - Hard Error" do
test "user with different oidc_id cannot be linked (hard error)" do
test "user with different oidc_id cannot be linked (hard error)", %{actor: actor} do
# Create user with existing OIDC ID
{:ok, existing_user} =
Mv.Accounts.User
@ -422,7 +448,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
email: "already-linked@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|> Ash.create()
|> Ash.create(actor: actor)
assert existing_user.oidc_id == "original_oidc_999"
@ -433,10 +459,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail with hard error (not PasswordVerificationRequired)
assert {:error, %Ash.Error.Invalid{}} = result
@ -459,7 +488,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do
end)
end
test "cannot link different oidc_id even with password verification" do
test "cannot link different oidc_id even with password verification", %{actor: actor} do
# Create user with password AND existing OIDC ID
existing_user =
create_test_user(%{
@ -478,10 +507,13 @@ defmodule MvWeb.OidcPasswordLinkingTest do
}
result =
Mv.Accounts.create_register_with_rauthy(%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
})
Mv.Accounts.create_register_with_rauthy(
%{
user_info: user_info,
oauth_tokens: %{"access_token" => "test_token"}
},
actor: actor
)
# Should fail - cannot link different OIDC ID
assert {:error, %Ash.Error.Invalid{}} = result

View file

@ -7,15 +7,20 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
"""
use MvWeb.ConnCase, async: true
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
describe "Passwordless user - Automatic linking via special action" do
test "passwordless user can be linked via link_passwordless_oidc action" do
test "passwordless user can be linked via link_passwordless_oidc action", %{actor: actor} 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()
|> Ash.create(actor: actor)
# Verify user has no password and no oidc_id
assert is_nil(existing_user.hashed_password)
@ -31,7 +36,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
"preferred_username" => "invited@example.com"
}
})
|> Ash.update()
|> Ash.update(actor: actor)
# User should now have oidc_id linked
assert linked_user.oidc_id == "auto_link_oidc_123"
@ -47,20 +52,22 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
},
oauth_tokens: %{"access_token" => "test_token"}
})
|> Ash.read_one()
|> Ash.read_one(actor: actor)
assert {:ok, signed_in_user} = result
assert signed_in_user.id == existing_user.id
end
test "passwordless user triggers PasswordVerificationRequired for linking flow" do
test "passwordless user triggers PasswordVerificationRequired for linking flow", %{
actor: actor
} do
# Create passwordless user
{:ok, existing_user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:create_user, %{
email: "passwordless@example.com"
})
|> Ash.create()
|> Ash.create(actor: actor)
assert is_nil(existing_user.hashed_password)
assert is_nil(existing_user.oidc_id)
@ -95,7 +102,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
end
describe "User with different OIDC ID - Hard Error" do
test "user with different oidc_id gets hard error, not password verification" do
test "user with different oidc_id gets hard error, not password verification", %{actor: actor} do
# Create user with existing OIDC ID
{:ok, _existing_user} =
Mv.Accounts.User
@ -103,7 +110,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
email: "already-linked@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|> Ash.create()
|> Ash.create(actor: actor)
# Try to register with same email but different OIDC ID
user_info = %{
@ -138,7 +145,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
end)
end
test "passwordless user with different oidc_id also gets hard error" do
test "passwordless user with different oidc_id also gets hard error", %{actor: actor} do
# Create passwordless user with OIDC ID
{:ok, existing_user} =
Mv.Accounts.User
@ -146,7 +153,7 @@ defmodule MvWeb.OidcPasswordlessLinkingTest do
email: "passwordless-linked@example.com"
})
|> Ash.Changeset.force_change_attribute(:oidc_id, "first_oidc_777")
|> Ash.create()
|> Ash.create(actor: actor)
assert is_nil(existing_user.hashed_password)
assert existing_user.oidc_id == "first_oidc_777"