Files
sure/test/models/invitation_test.rb
Josh 97391a27ba Share family accounts with invited members on every sign-up path (#2650)
* fix: share family accounts with invited members on every sign-up path

A user who joined an existing family through an invitation was added to the
family but saw none of its accounts, even when the family's default sharing is
"share with all members". Only Invitation#accept_for created the AccountShare
records; the OIDC just-in-time sign-up, invite-token registration, and mobile
SSO onboarding paths all skipped it, so invitees landed in the family with an
empty account list. Signups routed into an invite-only default family had the
same gap.

Extract the sharing into Family#auto_share_existing_accounts_with, the single
entry point for "a member just joined, apply the family's sharing policy". It
honors default_account_sharing, only shares with a persisted member of the
family, grants read_only to guests and read_write to everyone else, excludes
accounts the user already owns, and is idempotent. accept_for and every
sign-up path call it, so no current or future join path can reintroduce the
empty-account bug or share accounts across families.

Also wrap the two SSO account-creation paths (OIDC JIT and mobile SSO) in a
transaction covering the user save, invitation acceptance, account sharing, and
identity creation, so a failure partway through can no longer leave a
half-onboarded user with no linked identity.

* address review: guest read_only symmetric in auto_share_with_family!, load-bearing guard comment, mobile SSO private-no-op test
2026-07-14 01:39:26 +02:00

276 lines
10 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 "re-inviting an email with an expired unaccepted invitation replaces it without raising" do
email = "expired-reinvite@example.com"
expired = @family.invitations.create!(email: email, role: "member", inviter: @inviter)
expired.update_column(:expires_at, 1.day.ago)
invitation = nil
assert_nothing_raised do
invitation = @family.invitations.create!(email: email, role: "admin", inviter: @inviter)
end
assert invitation.pending?
assert_not Invitation.exists?(expired.id), "stale expired invitation should be removed"
assert_equal 1, @family.invitations.where(email: email).count
end
test "re-invite does not remove a still-pending duplicate (validation blocks first)" do
email = "still-pending@example.com"
pending = @family.invitations.create!(email: email, role: "member", inviter: @inviter)
duplicate = @family.invitations.build(email: email, role: "admin", inviter: @inviter)
assert_not duplicate.valid?
assert Invitation.exists?(pending.id), "an unexpired pending invitation must be preserved"
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
test "accept_for auto-shares existing family accounts when family shares by default" do
@family.update!(default_account_sharing: "shared")
user = users(:empty)
user.update_columns(family_id: families(:empty).id, role: "admin")
invitation = @family.invitations.create!(email: user.email, role: "member", inviter: @inviter)
expected_ids = @family.accounts.where.not(owner_id: user.id).pluck(:id).sort
assert expected_ids.any?
assert invitation.accept_for(user)
assert_equal expected_ids, AccountShare.where(user: user).pluck(:account_id).sort
end
test "accept_for auto-shares read_only for guest invitations" do
@family.update!(default_account_sharing: "shared")
user = users(:empty)
user.update_columns(family_id: families(:empty).id, role: "admin")
invitation = @family.invitations.create!(email: user.email, role: "guest", inviter: @inviter)
expected_ids = @family.accounts.where.not(owner_id: user.id).pluck(:id).sort
assert expected_ids.any?
assert invitation.accept_for(user)
user.reload
shares = AccountShare.where(user: user)
assert_equal "guest", user.role
assert_equal expected_ids, shares.pluck(:account_id).sort
assert shares.all?(&:read_only?), "guest invitation shares must grant read_only"
end
test "accept_for does not auto-share when family sharing is private" do
@family.update!(default_account_sharing: "private")
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_no_difference "AccountShare.count" do
assert invitation.accept_for(user)
end
end
end