mirror of
https://github.com/we-promise/sure.git
synced 2026-07-25 11:12:13 +00:00
* 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
276 lines
10 KiB
Ruby
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
|