mirror of
https://github.com/we-promise/sure.git
synced 2026-07-16 14:55:23 +00:00
* fix(invitations): allow re-inviting after an unaccepted invite expires An expired, never-accepted invitation still occupies the partial unique index `index_invitations_on_email_and_family_id_pending` (predicate `accepted_at IS NULL`), but the `pending` scope excludes it via the `expires_at > now` clause. The duplicate-guard validation therefore passes and the INSERT collides with the index, raising ActiveRecord::RecordNotUnique — surfaced to the admin as a 500 with no way to re-invite that address through the UI. Remove the stale expired row inside the create transaction (before_create) so the unique slot is freed and the re-invite succeeds. It runs only after validations pass, so a still-pending (non-expired) invitation can never be removed here. Add a controller-level rescue of ActiveRecord::RecordNotUnique as a safety net against a concurrent double-submit race. Closes #2535 * refactor(invitations): scope RecordNotUnique rescue to the save The method-level `rescue ActiveRecord::RecordNotUnique` wrapped the whole create action, so any future write after @invitation.save (e.g. accept_for) would silently be reported as a generic invite failure. Extract save_invitation to scope the rescue to the save alone, and add a regression test that drives the raced unique-index path directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
8.6 KiB
Ruby
233 lines
8.6 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
|
|
end
|