Skip to content

Commit 56b17a0

Browse files
fix(users/organizations/auth): prevent 500 on signin when org deleted without cascade (#3709) (#3713)
* fix(users): repair co-member currentOrganization on sole-owner deletion (#3709) Replace the bare OrganizationsCrudService.removeById call with an inline cascade that properly repairs co-member currentOrganization to their next available org (or null) instead of clearing it uniformly to null. * fix(organizations): harden autoSetCurrentOrganization to skip null-populated org refs (#3709) Filter memberships whose organizationId is null after Mongoose populate (deleted org with dangling ObjectId ref) before dereferencing ._id. Also guard the early-return branch: only trust a membership as still-active when its populated organizationId is non-null. * fix(auth): add regression guard for data-integrity anomaly on signin (#3709) Add E2E test verifying signin returns 200 (not 500) when currentOrganization points to a non-existent org ID (pre-existing corruption scenario). Tasks 1+2 already fix the root cause; this test is the belt-and-suspenders regression guard. * fix(users): guard null org in deletion-cascade nextOrg (#3709) Co-member cascade in `users.service.remove` was dereferencing `remaining[0].organizationId._id` without filtering out null-populated memberships (hard-deleted org), mirroring the same crash fixed in `autoSetCurrentOrganization`. Apply the identical `liveMemberships` filter before picking the next current org. Add unit tests covering the null-only and mixed cases.
1 parent 8a50c8e commit 56b17a0

6 files changed

Lines changed: 484 additions & 10 deletions

File tree

modules/auth/tests/auth.e2e.tests.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,42 @@ describe('Auth E2E tests:', () => {
237237
expect(err).toBeFalsy();
238238
}
239239
});
240+
241+
test('should return 200 on signin even when autoSetCurrentOrganization would encounter a data-integrity anomaly', async () => {
242+
config.organizations = { enabled: true, autoCreate: true, domainMatching: false };
243+
244+
// Signup user, then directly corrupt their currentOrganization to a non-existent ObjectId
245+
// to simulate a pre-existing dangling ref in prod
246+
try {
247+
const signupRes = await agent
248+
.post('/api/auth/signup')
249+
.send({
250+
firstName: 'Danglingref',
251+
lastName: 'User',
252+
email: 'e2e-dangling-ref-3709@test.com',
253+
password: 'W@os.jsI$Aw3$0m3',
254+
provider: 'local',
255+
})
256+
.expect(200);
257+
258+
user = signupRes.body.user;
259+
// Directly write a bogus ObjectId as currentOrganization (simulates pre-existing corruption)
260+
const mongoose = (await import('mongoose')).default;
261+
const User = mongoose.model('User');
262+
await User.updateOne({ _id: user.id }, { currentOrganization: new mongoose.Types.ObjectId() });
263+
264+
// Signin must NOT 500
265+
const signinRes = await agent
266+
.post('/api/auth/signin')
267+
.send({ email: 'e2e-dangling-ref-3709@test.com', password: 'W@os.jsI$Aw3$0m3' })
268+
.expect(200);
269+
270+
expect(signinRes.body.type).toBe('success');
271+
} catch (err) {
272+
console.log(err);
273+
expect(err).toBeFalsy();
274+
}
275+
});
240276
});
241277

242278
// Mongoose disconnect

modules/organizations/services/organizations.crud.service.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,13 +253,16 @@ const autoSetCurrentOrganization = async (user) => {
253253
organizationId: user.currentOrganization._id || user.currentOrganization,
254254
status: MEMBERSHIP_STATUSES.ACTIVE,
255255
});
256-
if (stillActive) return user;
257-
// Membership gone — clear stale reference and fall through to find another
256+
// Guard: populated organizationId may be null when the org was hard-deleted
257+
if (stillActive && stillActive.organizationId != null) return user;
258+
// Membership gone or org deleted — clear stale reference and fall through to find another
258259
user.currentOrganization = null;
259260
}
260261
const memberships = await MembershipRepository.list({ userId: user._id || user.id, status: MEMBERSHIP_STATUSES.ACTIVE });
261-
if (memberships.length > 0) {
262-
const orgId = memberships[0].organizationId._id || memberships[0].organizationId;
262+
// Filter out memberships whose org was deleted (Mongoose populate sets organizationId to null)
263+
const liveMemberships = memberships.filter((m) => m.organizationId != null);
264+
if (liveMemberships.length > 0) {
265+
const orgId = liveMemberships[0].organizationId._id || liveMemberships[0].organizationId;
263266
await UserService.updateById(user._id || user.id, { currentOrganization: orgId });
264267
user.currentOrganization = orgId;
265268
} else {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Unit tests — autoSetCurrentOrganization must not crash when membership.organizationId
3+
* is null after populate (deleted org, dangling ref). Issue #3709.
4+
*/
5+
import { jest, describe, test, expect } from '@jest/globals';
6+
7+
const mockMembershipFindOne = jest.fn();
8+
const mockMembershipList = jest.fn();
9+
const mockUpdateById = jest.fn();
10+
const mockOrgRemove = jest.fn();
11+
12+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
13+
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() },
14+
}));
15+
16+
jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({
17+
default: {
18+
create: jest.fn(),
19+
findOne: jest.fn().mockResolvedValue(null),
20+
remove: mockOrgRemove,
21+
list: jest.fn().mockResolvedValue([]),
22+
update: jest.fn(),
23+
get: jest.fn(),
24+
exists: jest.fn().mockResolvedValue(false),
25+
updateById: jest.fn(),
26+
},
27+
}));
28+
29+
jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({
30+
default: {
31+
create: jest.fn(),
32+
deleteMany: jest.fn(),
33+
list: mockMembershipList,
34+
findOne: mockMembershipFindOne,
35+
update: jest.fn(),
36+
remove: jest.fn(),
37+
count: jest.fn(),
38+
},
39+
}));
40+
41+
jest.unstable_mockModule('../../users/services/users.service.js', () => ({
42+
default: {
43+
updateById: mockUpdateById,
44+
findWithFilter: jest.fn().mockResolvedValue([]),
45+
getBrut: jest.fn(),
46+
},
47+
}));
48+
49+
jest.unstable_mockModule('../../../lib/helpers/emailVerification.js', () => ({
50+
assertEmailVerified: jest.fn(),
51+
}));
52+
53+
jest.unstable_mockModule('../../../config/index.js', () => ({
54+
default: { organizations: { enabled: true } },
55+
}));
56+
57+
const { default: OrgCrudService } = await import('../services/organizations.crud.service.js');
58+
59+
describe('autoSetCurrentOrganization — dangling org ref (#3709):', () => {
60+
const user = { _id: 'uid1', id: 'uid1', currentOrganization: null };
61+
62+
beforeEach(() => {
63+
jest.clearAllMocks();
64+
mockUpdateById.mockResolvedValue({});
65+
});
66+
67+
test('should not throw when membership.organizationId is null after populate', async () => {
68+
// Simulate: user has an active membership but the org is deleted (populate yields null)
69+
mockMembershipList.mockResolvedValue([
70+
{ _id: 'm1', organizationId: null, status: 'active' }, // null = deleted org
71+
]);
72+
73+
// Must not throw; must set currentOrganization to null (no live org)
74+
const result = await OrgCrudService.autoSetCurrentOrganization({ ...user });
75+
expect(result.currentOrganization).toBeNull();
76+
expect(mockUpdateById).toHaveBeenCalledWith('uid1', { currentOrganization: null });
77+
});
78+
79+
test('should skip null-org memberships and pick first live org when mixed', async () => {
80+
// Two memberships: first has deleted org (null populate), second has live org
81+
mockMembershipList.mockResolvedValue([
82+
{ _id: 'm1', organizationId: null, status: 'active' }, // deleted org
83+
{ _id: 'm2', organizationId: { _id: 'org2' }, status: 'active' }, // live org
84+
]);
85+
86+
const result = await OrgCrudService.autoSetCurrentOrganization({ ...user });
87+
expect(result.currentOrganization).toBe('org2');
88+
expect(mockUpdateById).toHaveBeenCalledWith('uid1', { currentOrganization: 'org2' });
89+
});
90+
91+
test('should return user unchanged when currentOrganization is set and membership is live with a live org', async () => {
92+
const userWithOrg = { _id: 'uid1', id: 'uid1', currentOrganization: { _id: 'org1' } };
93+
mockMembershipFindOne.mockResolvedValue({ _id: 'm1', organizationId: { _id: 'org1' } });
94+
95+
const result = await OrgCrudService.autoSetCurrentOrganization(userWithOrg);
96+
// Early return — no updateById called
97+
expect(mockUpdateById).not.toHaveBeenCalled();
98+
expect(result.currentOrganization).toEqual({ _id: 'org1' });
99+
});
100+
101+
test('should fall through and clear currentOrganization when membership exists but org is null-populated', async () => {
102+
// Membership still exists (findOne returns it) but org is deleted (organizationId = null)
103+
const userWithOrg = { _id: 'uid1', id: 'uid1', currentOrganization: { _id: 'org1' } };
104+
// findOne called in early branch — membership exists (stillActive truthy) but org deleted
105+
mockMembershipFindOne.mockResolvedValue({ _id: 'm1', organizationId: null });
106+
// list called in fallback branch — also returns the same dangling membership
107+
mockMembershipList.mockResolvedValue([
108+
{ _id: 'm1', organizationId: null, status: 'active' },
109+
]);
110+
111+
const result = await OrgCrudService.autoSetCurrentOrganization(userWithOrg);
112+
expect(result.currentOrganization).toBeNull();
113+
expect(mockUpdateById).toHaveBeenCalledWith('uid1', { currentOrganization: null });
114+
});
115+
});

modules/users/services/users.service.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import config from '../../../config/index.js';
77
import AuthService from '../../auth/services/auth.service.js';
88
import UserRepository from '../repositories/users.repository.js';
99
import MembershipService from '../../organizations/services/organizations.membership.service.js';
10-
import OrganizationsCrudService from '../../organizations/services/organizations.crud.service.js';
10+
import OrganizationsRepository from '../../organizations/repositories/organizations.repository.js';
11+
import MembershipRepository from '../../organizations/repositories/organizations.membership.repository.js';
1112
import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../../organizations/lib/constants.js';
1213
import { removeSensitive } from '../utils/sanitizeUser.js';
1314

@@ -119,12 +120,21 @@ const remove = async (user) => {
119120
// Check if this user is the only owner of the org
120121
const ownerCount = await MembershipService.count({ organizationId: orgId, role: MEMBERSHIP_ROLES.OWNER, status: MEMBERSHIP_STATUSES.ACTIVE });
121122
if (ownerCount <= 1) {
122-
// Sole owner — delete the entire org and its memberships
123-
// Clear currentOrganization for affected users
124-
await UserRepository.updateMany({ currentOrganization: orgId }, { currentOrganization: null });
123+
// Sole owner — delete org and cascade: repair co-member currentOrganization
124+
// Step 1: Collect co-members whose currentOrganization points to this org
125+
const affectedUsers = await UserRepository.findWithFilter({ currentOrganization: orgId }, '_id');
126+
// Step 2: Delete all memberships for this org (including this user's and all co-members')
125127
await MembershipService.deleteMany({ organizationId: orgId });
126-
await OrganizationsCrudService.removeById(orgId);
127-
continue; // membership already deleted above
128+
// Step 3: For each affected co-member, switch to their next available org or set null
129+
await Promise.all(affectedUsers.map(async (u) => {
130+
const remaining = await MembershipRepository.list({ userId: u._id, status: MEMBERSHIP_STATUSES.ACTIVE });
131+
const liveMemberships = remaining.filter((m) => m.organizationId != null);
132+
const nextOrg = liveMemberships.length > 0 ? (liveMemberships[0].organizationId._id || liveMemberships[0].organizationId) : null;
133+
await UserRepository.updateById(u._id, { currentOrganization: nextOrg });
134+
}));
135+
// Step 4: Delete the org (bare remove — org-scoped tasks are intentionally not deleted here)
136+
await OrganizationsRepository.remove({ _id: orgId });
137+
continue; // memberships for this org already cleaned up
128138
}
129139
}
130140
// Delete this user's membership
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* E2E test: user deletion cascades org cleanup so co-members can still sign in.
3+
* Repro from issue #3709: User A (sole owner) deleted → Org X removed.
4+
* User B (active member of Org X, currentOrganization=X) must be able to sign in
5+
* without 500, with currentOrganization cleared to null.
6+
*/
7+
import request from 'supertest';
8+
import path from 'path';
9+
import { bootstrap } from '../../../lib/app.js';
10+
import mongooseService from '../../../lib/services/mongoose.js';
11+
import config from '../../../config/index.js';
12+
13+
describe('users.service.remove cascade (#3709):', () => {
14+
let UserService;
15+
let OrganizationsRepository;
16+
let MembershipRepository;
17+
let agent;
18+
19+
const originalOrganizations = { ...config.organizations };
20+
21+
beforeAll(async () => {
22+
try {
23+
const init = await bootstrap();
24+
UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default;
25+
OrganizationsRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.repository.js'))).default;
26+
MembershipRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.membership.repository.js'))).default;
27+
agent = request.agent(init.app);
28+
} catch (err) {
29+
console.log(err);
30+
expect(err).toBeFalsy();
31+
}
32+
});
33+
34+
afterAll(async () => {
35+
config.organizations = { ...originalOrganizations };
36+
try {
37+
await mongooseService.disconnect();
38+
} catch (err) {
39+
console.log(err);
40+
expect(err).toBeFalsy();
41+
}
42+
});
43+
44+
describe('User B can sign in after User A (sole owner) is deleted', () => {
45+
let userA;
46+
let userB;
47+
let orgX;
48+
let agentA;
49+
let agentB;
50+
51+
beforeAll(async () => {
52+
config.organizations = { enabled: true, autoCreate: true, domainMatching: false };
53+
agentA = request.agent(agent.app);
54+
agentB = request.agent(agent.app);
55+
});
56+
57+
afterAll(async () => {
58+
// Best-effort cleanup
59+
try {
60+
if (userB) await UserService.remove(userB);
61+
} catch (_) { /* cleanup */ }
62+
try {
63+
if (orgX) {
64+
await MembershipRepository.deleteMany({ organizationId: orgX._id });
65+
await OrganizationsRepository.deleteMany({ _id: orgX._id });
66+
}
67+
} catch (_) { /* cleanup */ }
68+
});
69+
70+
test('should not 500 on signin when currentOrganization points to a deleted org (issue #3709 repro)', async () => {
71+
// Step 1: User A signs up — auto-creates Org X
72+
try {
73+
const resA = await agentA
74+
.post('/api/auth/signup')
75+
.send({
76+
firstName: 'CascadeA',
77+
lastName: 'User',
78+
email: 'cascade-a-3709@test.com',
79+
password: 'W@os.jsI$Aw3$0m3',
80+
provider: 'local',
81+
})
82+
.expect(200);
83+
userA = resA.body.user;
84+
orgX = resA.body.organization;
85+
expect(orgX).toBeDefined();
86+
expect(orgX).not.toBeNull();
87+
} catch (err) {
88+
console.log(err);
89+
expect(err).toBeFalsy();
90+
}
91+
92+
// Step 2: User B signs up separately
93+
try {
94+
const resB = await agentB
95+
.post('/api/auth/signup')
96+
.send({
97+
firstName: 'CascadeB',
98+
lastName: 'User',
99+
email: 'cascade-b-3709@test.com',
100+
password: 'W@os.jsI$Aw3$0m3',
101+
provider: 'local',
102+
})
103+
.expect(200);
104+
userB = resB.body.user;
105+
} catch (err) {
106+
console.log(err);
107+
expect(err).toBeFalsy();
108+
}
109+
110+
// Step 3: Directly create an ACTIVE membership for User B on Org X + set currentOrganization
111+
// (bypassing invite flow for test speed)
112+
try {
113+
const MembershipService = (await import(path.resolve('./modules/organizations/services/organizations.membership.service.js'))).default;
114+
await MembershipService.create({
115+
userId: userB._id || userB.id,
116+
organizationId: orgX._id,
117+
role: 'member',
118+
status: 'active',
119+
});
120+
// Set User B's currentOrganization to Org X directly
121+
await UserService.updateById(userB._id || userB.id, { currentOrganization: orgX._id });
122+
} catch (err) {
123+
console.log(err);
124+
expect(err).toBeFalsy();
125+
}
126+
127+
// Step 4: Delete User A (sole owner of Org X) — this should cascade-delete Org X + all memberships
128+
try {
129+
const brutUserA = await UserService.getBrut({ id: userA.id });
130+
await UserService.remove(brutUserA);
131+
userA = null; // Mark as cleaned
132+
} catch (err) {
133+
console.log(err);
134+
expect(err).toBeFalsy();
135+
}
136+
137+
// Step 5: Verify Org X no longer exists
138+
try {
139+
const org = await OrganizationsRepository.get(orgX._id);
140+
expect(org).toBeNull();
141+
} catch (err) {
142+
console.log(err);
143+
expect(err).toBeFalsy();
144+
}
145+
146+
// Step 6: Verify User B's currentOrganization has been cleared (not left dangling)
147+
try {
148+
const brutUserB = await UserService.getBrut({ id: userB.id });
149+
// currentOrganization must be null or undefined — not the deleted org ID
150+
const currentOrgId = brutUserB.currentOrganization?._id || brutUserB.currentOrganization;
151+
expect(currentOrgId == null || String(currentOrgId) !== String(orgX._id)).toBe(true);
152+
} catch (err) {
153+
console.log(err);
154+
expect(err).toBeFalsy();
155+
}
156+
157+
// Step 7: User B signs in — must NOT 500 (was: "Cannot read properties of null (reading '_id')")
158+
try {
159+
const signinRes = await agentB
160+
.post('/api/auth/signin')
161+
.send({ email: 'cascade-b-3709@test.com', password: 'W@os.jsI$Aw3$0m3' })
162+
.expect(200); // Must be 200, not 500
163+
164+
expect(signinRes.body.type).toBe('success');
165+
// currentOrganization must NOT be the deleted Org X (could be null or User B's own org)
166+
const signedInOrgId = signinRes.body.user.currentOrganization?._id || signinRes.body.user.currentOrganization;
167+
expect(signedInOrgId == null || String(signedInOrgId) !== String(orgX._id)).toBe(true);
168+
} catch (err) {
169+
console.log(err);
170+
expect(err).toBeFalsy();
171+
}
172+
});
173+
});
174+
});

0 commit comments

Comments
 (0)