Files
sure/test/models/invitation_test.rb
dripsmvcp ab52b2b144 fix(family-sharing): prevent silent data loss when rehoming or removing users (#1896)
* fix(family-sharing): prevent silent data loss when rehoming or removing users

Fixes #1689.

Two destructive paths could strand a pre-existing user's family and accounts:

1. Invitation#accept_for unconditionally overwrote user.family_id, orphaning
   the prior family + its accounts with no user able to reach them.
2. Settings::ProfilesController#destroy then called @user.destroy when an admin
   removed the rehomed member, destroying the only login path back to the
   now-orphaned data.

Add hard-block guards on both paths. accept_for refuses when the invitee
already belongs to a family with accounts; ProfilesController#destroy refuses
when the member owns accounts in another family (legacy state from the old
flow). InvitationsController#create surfaces a specific, actionable flash so
the admin understands why the auto-accept was refused.

No automatic recovery of already-orphaned data — that needs a separate
one-shot script per dosubot's analysis on the issue.

* fix(family-sharing): scope invite orphan-guard to invitee-owned accounts (#1896 review)

Codex flagged (P1) and the maintainer review independently raised that
would_orphan_existing_family? keyed off user.family.accounts.exists? —
any account in the invitee's current family — which wrongly blocked a
non-owner member from leaving a multi-user household.

Rename to would_orphan_owned_accounts? and key off
user.owned_accounts.where.not(family_id: family_id), making the invite
guard symmetric with the destroy-path guard in
Settings::ProfilesController. A member who owns no accounts now orphans
nothing by moving and is free to accept the invitation; an owner is
still blocked.

Add a regression test for the non-owner case and update the existing
tests to give the invitee explicit account ownership.

* Remove extra comments per project conventions

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-27 23:25:46 +02:00

207 lines
7.5 KiB
Ruby

require "test_helper"
class InvitationTest < ActiveSupport::TestCase
setup do
@invitation = invitations(:one)
@family = @invitation.family
@inviter = @invitation.inviter
end
test "accept_for adds user to family when email matches" do
user = users(:empty)
user.update_columns(family_id: families(:empty).id, role: "admin")
assert user.family_id != @family.id
invitation = @family.invitations.create!(email: user.email, role: "member", inviter: @inviter)
assert invitation.pending?
result = invitation.accept_for(user)
assert result
user.reload
assert_equal @family.id, user.family_id
assert_equal "member", user.role
invitation.reload
assert invitation.accepted_at.present?
end
test "accept_for returns false when user email does not match" do
user = users(:family_member)
assert user.email != @invitation.email
result = @invitation.accept_for(user)
assert_not result
user.reload
assert_equal families(:dylan_family).id, user.family_id
@invitation.reload
assert_nil @invitation.accepted_at
end
test "accept_for updates role when user already in family" do
user = users(:family_member)
user.update!(family_id: @family.id, role: "member")
invitation = @family.invitations.create!(email: user.email, role: "admin", inviter: @inviter)
original_family_id = user.family_id
result = invitation.accept_for(user)
assert result
user.reload
assert_equal original_family_id, user.family_id
assert_equal "admin", user.role
invitation.reload
assert invitation.accepted_at.present?
end
test "accept_for returns false when invitation not pending" do
@invitation.update!(accepted_at: 1.hour.ago)
user = users(:empty)
result = @invitation.accept_for(user)
assert_not result
end
test "cannot create invitation when email has pending invitation from another family" do
other_family = families(:empty)
other_inviter = users(:empty)
other_inviter.update_columns(family_id: other_family.id, role: "admin")
email = "cross-family-test@example.com"
# Create a pending invitation in the first family
@family.invitations.create!(email: email, role: "member", inviter: @inviter)
# Attempting to create a pending invitation in a different family should fail
invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter)
assert_not invitation.valid?
assert_includes invitation.errors[:email], "already has a pending invitation from another family"
end
test "can create invitation when existing invitation from another family is accepted" do
other_family = families(:empty)
other_inviter = users(:empty)
other_inviter.update_columns(family_id: other_family.id, role: "admin")
email = "cross-family-accepted@example.com"
# Create an accepted invitation in the first family
accepted_invitation = @family.invitations.create!(email: email, role: "member", inviter: @inviter)
accepted_invitation.update!(accepted_at: Time.current)
# Should be able to create a pending invitation in a different family
invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter)
assert invitation.valid?
end
test "can create invitation when existing invitation from another family is expired" do
other_family = families(:empty)
other_inviter = users(:empty)
other_inviter.update_columns(family_id: other_family.id, role: "admin")
email = "cross-family-expired@example.com"
# Create an expired invitation in the first family
expired_invitation = @family.invitations.create!(email: email, role: "member", inviter: @inviter)
expired_invitation.update_columns(expires_at: 1.day.ago)
# Should be able to create a pending invitation in a different family
invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter)
assert invitation.valid?
end
test "can create invitation in same family (uniqueness scoped to family)" do
email = "same-family-test@example.com"
# Create a pending invitation in the family
@family.invitations.create!(email: email, role: "member", inviter: @inviter)
# Attempting to create another in the same family should fail due to the existing scope validation
invitation = @family.invitations.build(email: email, role: "admin", inviter: @inviter)
assert_not invitation.valid?
assert_includes invitation.errors[:email], "has already been invited to this family"
end
test "accept_for refuses when invitee owns accounts that would be orphaned" do
owner = users(:empty)
owner_family = families(:empty)
owner.update_columns(family_id: owner_family.id, role: "admin")
account = owner_family.accounts.create!(
name: "Prior savings", balance: 100, currency: "USD",
accountable: Depository.new
)
account.update_columns(owner_id: owner.id)
invitation = @family.invitations.create!(email: owner.email, role: "member", inviter: @inviter)
result = invitation.accept_for(owner)
assert_not result, "accept_for must refuse to rehome a user away from accounts they own"
owner.reload
assert_equal owner_family.id, owner.family_id, "user.family_id must not be silently overwritten"
invitation.reload
assert_nil invitation.accepted_at, "invitation must remain pending so a new flow can recover"
assert owner_family.accounts.exists?, "original family's accounts must remain intact"
end
test "accept_for allows a member who owns no accounts to join another family" do
member = users(:empty)
other_owner = users(:sure_support_staff)
source_family = families(:empty)
member.update_columns(family_id: source_family.id, role: "member")
other_owner.update_columns(family_id: source_family.id, role: "admin")
account = source_family.accounts.create!(
name: "Shared savings", balance: 100, currency: "USD",
accountable: Depository.new
)
account.update_columns(owner_id: other_owner.id)
invitation = @family.invitations.create!(email: member.email, role: "member", inviter: @inviter)
result = invitation.accept_for(member)
assert result, "a non-owner member must be free to join another family"
member.reload
assert_equal @family.id, member.family_id
end
test "would_orphan_owned_accounts? is false when invitee owns no accounts" do
user = users(:empty)
user.update_columns(family_id: families(:empty).id, role: "admin")
invitation = @family.invitations.create!(email: user.email, role: "member", inviter: @inviter)
assert_not invitation.would_orphan_owned_accounts?(user)
end
test "would_orphan_owned_accounts? is false when same-family role change" do
user = users(:family_member)
user.update!(family_id: @family.id, role: "member")
invitation = @family.invitations.create!(email: user.email, role: "admin", inviter: @inviter)
assert_not invitation.would_orphan_owned_accounts?(user)
end
test "accept_for applies guest role defaults" do
user = users(:family_member)
user.update!(
family_id: @family.id,
role: "member",
ui_layout: "dashboard",
show_sidebar: true,
show_ai_sidebar: true,
ai_enabled: false
)
invitation = @family.invitations.create!(email: user.email, role: "guest", inviter: @inviter)
result = invitation.accept_for(user)
assert result
user.reload
assert_equal "guest", user.role
assert user.ui_layout_intro?
assert_not user.show_sidebar?
assert_not user.show_ai_sidebar?
assert user.ai_enabled?
end
end