fix(backend): unable to delete an organization after inviting at least one member (#12993)

This commit is contained in:
Hiep Le
2026-02-23 18:21:10 +07:00
committed by GitHub
parent ddb809bc43
commit 0ec97893d1
3 changed files with 90 additions and 1 deletions

View File

@@ -51,7 +51,9 @@ class Org(Base): # type: ignore
# Relationships
org_members = relationship('OrgMember', back_populates='org')
current_users = relationship('User', back_populates='current_org')
invitations = relationship('OrgInvitation', back_populates='org')
invitations = relationship(
'OrgInvitation', back_populates='org', passive_deletes=True
)
billing_sessions = relationship('BillingSession', back_populates='org')
stored_conversation_metadata_saas = relationship(
'StoredConversationMetadataSaas', back_populates='org'

View File

@@ -15,6 +15,7 @@ from storage.device_code import DeviceCode # noqa: F401
from storage.feedback import Feedback
from storage.github_app_installation import GithubAppInstallation
from storage.org import Org
from storage.org_invitation import OrgInvitation # noqa: F401
from storage.org_member import OrgMember
from storage.role import Role
from storage.stored_conversation_metadata import StoredConversationMetadata

View File

@@ -10,6 +10,7 @@ with patch('storage.database.engine', create=True), patch(
'storage.database.a_engine', create=True
):
from storage.org import Org
from storage.org_invitation import OrgInvitation
from storage.org_member import OrgMember
from storage.org_store import OrgStore
from storage.role import Role
@@ -806,3 +807,88 @@ def test_orphaned_user_error_contains_user_ids():
assert error.user_ids == user_ids
assert '2 user(s)' in str(error)
assert 'no remaining organization' in str(error)
def test_org_deletion_with_invitations_uses_passive_deletes(
session_maker, mock_litellm_api
):
"""
GIVEN: Organization has associated invitations with non-nullable org_id foreign key
WHEN: Organization is deleted via SQLAlchemy session.delete()
THEN: Deletion succeeds without NOT NULL constraint violation
(passive_deletes=True defers to database CASCADE instead of setting org_id to NULL)
This test verifies the fix for the bug where SQLAlchemy would try to
SET org_id=NULL on org_invitation before deleting the org, causing:
"NOT NULL constraint failed: org_invitation.org_id"
With passive_deletes=True on the relationship, SQLAlchemy defers to the
database's CASCADE constraint instead of trying to nullify the foreign key.
Note: SQLite doesn't enforce CASCADE by default, so we only verify that
the deletion succeeds. In production (PostgreSQL), CASCADE handles cleanup.
"""
from datetime import datetime, timedelta
# Arrange
org_id = uuid.uuid4()
other_org_id = uuid.uuid4()
user_id = uuid.uuid4()
with session_maker() as session:
# Create role first (required for invitation)
role = Role(id=1, name='owner', rank=1)
session.add(role)
session.flush()
# Create organization to be deleted
org = Org(id=org_id, name='test-org-with-invitations')
session.add(org)
session.flush()
# Create a second org for the user's current_org_id
# (to avoid the user.current_org_id constraint issue during deletion)
other_org = Org(id=other_org_id, name='other-org')
session.add(other_org)
session.flush()
# Create user with current_org pointing to the OTHER org (not the one being deleted)
user = User(id=user_id, current_org_id=other_org_id)
session.add(user)
session.flush()
# Create invitation associated with the organization to be deleted
invitation = OrgInvitation(
token='test-invitation-token-12345',
org_id=org_id,
email='invitee@example.com',
role_id=1,
inviter_id=user_id,
status='pending',
created_at=datetime.now(),
expires_at=datetime.now() + timedelta(days=7),
)
session.add(invitation)
session.commit()
# Verify invitation was created
invitation_count = session.query(OrgInvitation).filter_by(org_id=org_id).count()
assert invitation_count == 1
# Act - Delete organization via SQLAlchemy (this is what triggered the bug)
# Without passive_deletes=True, SQLAlchemy would try to SET org_id=NULL
# which violates the NOT NULL constraint on org_invitation.org_id
with session_maker() as session:
org = session.query(Org).filter(Org.id == org_id).first()
assert org is not None
# This should NOT raise IntegrityError with passive_deletes=True
# Previously this would fail with:
# "NOT NULL constraint failed: org_invitation.org_id"
session.delete(org)
session.commit() # Success indicates passive_deletes=True is working
# Assert - Organization should be deleted
with session_maker() as session:
deleted_org = session.query(Org).filter(Org.id == org_id).first()
assert deleted_org is None