From 0ec97893d1ee9db3a3d6b0fae20d9c9c3eaeef4a Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:21:10 +0700 Subject: [PATCH] fix(backend): unable to delete an organization after inviting at least one member (#12993) --- enterprise/storage/org.py | 4 +- enterprise/tests/unit/conftest.py | 1 + enterprise/tests/unit/test_org_store.py | 86 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/enterprise/storage/org.py b/enterprise/storage/org.py index e52be4d6f0..b0ec98b0a2 100644 --- a/enterprise/storage/org.py +++ b/enterprise/storage/org.py @@ -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' diff --git a/enterprise/tests/unit/conftest.py b/enterprise/tests/unit/conftest.py index bf8e762357..736dc8e808 100644 --- a/enterprise/tests/unit/conftest.py +++ b/enterprise/tests/unit/conftest.py @@ -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 diff --git a/enterprise/tests/unit/test_org_store.py b/enterprise/tests/unit/test_org_store.py index 2547958550..91ba4b60b7 100644 --- a/enterprise/tests/unit/test_org_store.py +++ b/enterprise/tests/unit/test_org_store.py @@ -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