Skip to content

Commit 6bce74b

Browse files
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.
1 parent 2207602 commit 6bce74b

2 files changed

Lines changed: 122 additions & 4 deletions

File tree

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+
});

0 commit comments

Comments
 (0)