Skip to content

Commit 984ae97

Browse files
[PM-31004]: Fix Stackoverflow from Circular Group References (#991)
* Fix circular groups * Simplify tests
1 parent af43015 commit 984ae97

File tree

3 files changed

+455
-2
lines changed

3 files changed

+455
-2
lines changed

src/services/sync.service.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { MessagingService } from "@/jslib/common/src/abstractions/messaging.serv
66
import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest";
77
import { ApiService } from "@/jslib/common/src/services/api.service";
88

9+
import { GroupEntry } from "@/src/models/groupEntry";
10+
911
import { getSyncConfiguration } from "../../utils/openldap/config-fixtures";
1012
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
1113
import { DirectoryType } from "../enums/directoryType";
@@ -134,4 +136,134 @@ describe("SyncService", () => {
134136

135137
expect(apiService.postPublicImportDirectory).not.toHaveBeenCalled();
136138
});
139+
140+
describe("nested and circular group handling", () => {
141+
function createGroup(
142+
name: string,
143+
userExternalIds: string[] = [],
144+
groupMemberReferenceIds: string[] = [],
145+
) {
146+
return GroupEntry.fromJSON({
147+
name,
148+
referenceId: name,
149+
externalId: name,
150+
userMemberExternalIds: userExternalIds,
151+
groupMemberReferenceIds: groupMemberReferenceIds,
152+
users: [],
153+
});
154+
}
155+
156+
function setupSyncWithGroups(groups: GroupEntry[]) {
157+
const mockDirectoryService = mock<LdapDirectoryService>();
158+
mockDirectoryService.getEntries.mockResolvedValue([groups, []]);
159+
directoryFactory.createService.mockReturnValue(mockDirectoryService);
160+
161+
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
162+
cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));
163+
stateService.getLastSyncHash.mockResolvedValue("unique hash");
164+
singleRequestBuilder.buildRequest.mockReturnValue([
165+
{ members: [], groups: [], overwriteExisting: true, largeImport: false },
166+
]);
167+
}
168+
169+
it("should handle simple circular reference (A ↔ B) without stack overflow", async () => {
170+
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
171+
const groupB = createGroup("GroupB", ["userB"], ["GroupA"]);
172+
setupSyncWithGroups([groupA, groupB]);
173+
174+
const [groups] = await syncService.sync(true, true);
175+
176+
const [a, b] = groups;
177+
expect(a.userMemberExternalIds).toEqual(new Set(["userA", "userB"]));
178+
expect(b.userMemberExternalIds).toEqual(new Set(["userA", "userB"]));
179+
});
180+
181+
it("should handle longer circular chain (A → B → C → A) without stack overflow", async () => {
182+
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
183+
const groupB = createGroup("GroupB", ["userB"], ["GroupC"]);
184+
const groupC = createGroup("GroupC", ["userC"], ["GroupA"]);
185+
setupSyncWithGroups([groupA, groupB, groupC]);
186+
187+
const [groups] = await syncService.sync(true, true);
188+
189+
const allUsers = new Set(["userA", "userB", "userC"]);
190+
for (const group of groups) {
191+
expect(group.userMemberExternalIds).toEqual(allUsers);
192+
}
193+
});
194+
195+
it("should handle diamond structure (A → [B, C] → D)", async () => {
196+
const groupA = createGroup("GroupA", ["userA"], ["GroupB", "GroupC"]);
197+
const groupB = createGroup("GroupB", ["userB"], ["GroupD"]);
198+
const groupC = createGroup("GroupC", ["userC"], ["GroupD"]);
199+
const groupD = createGroup("GroupD", ["userD"], []);
200+
setupSyncWithGroups([groupA, groupB, groupC, groupD]);
201+
202+
const [groups] = await syncService.sync(true, true);
203+
204+
const [a, b, c, d] = groups;
205+
expect(a.userMemberExternalIds).toEqual(new Set(["userA", "userB", "userC", "userD"]));
206+
expect(b.userMemberExternalIds).toEqual(new Set(["userB", "userD"]));
207+
expect(c.userMemberExternalIds).toEqual(new Set(["userC", "userD"]));
208+
expect(d.userMemberExternalIds).toEqual(new Set(["userD"]));
209+
});
210+
211+
it("should handle deep nesting with circular reference at leaf", async () => {
212+
// Structure: A → B → C → D → B (cycle back to B)
213+
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
214+
const groupB = createGroup("GroupB", ["userB"], ["GroupC"]);
215+
const groupC = createGroup("GroupC", ["userC"], ["GroupD"]);
216+
const groupD = createGroup("GroupD", ["userD"], ["GroupB"]);
217+
setupSyncWithGroups([groupA, groupB, groupC, groupD]);
218+
219+
const [groups] = await syncService.sync(true, true);
220+
221+
const [a, b, c, d] = groups;
222+
const cycleUsers = new Set(["userB", "userC", "userD"]);
223+
expect(a.userMemberExternalIds).toEqual(new Set(["userA", ...cycleUsers]));
224+
expect(b.userMemberExternalIds).toEqual(cycleUsers);
225+
expect(c.userMemberExternalIds).toEqual(cycleUsers);
226+
expect(d.userMemberExternalIds).toEqual(cycleUsers);
227+
});
228+
229+
it("should handle complex structure with multiple cycles and shared members", async () => {
230+
// Structure:
231+
// A → [B, C]
232+
// B → [D, E]
233+
// C → [E, F]
234+
// D → A (cycle)
235+
// E → C (cycle)
236+
// F → (leaf)
237+
const groupA = createGroup("GroupA", ["userA"], ["GroupB", "GroupC"]);
238+
const groupB = createGroup("GroupB", ["userB"], ["GroupD", "GroupE"]);
239+
const groupC = createGroup("GroupC", ["userC"], ["GroupE", "GroupF"]);
240+
const groupD = createGroup("GroupD", ["userD"], ["GroupA"]);
241+
const groupE = createGroup("GroupE", ["userE"], ["GroupC"]);
242+
const groupF = createGroup("GroupF", ["userF"], []);
243+
setupSyncWithGroups([groupA, groupB, groupC, groupD, groupE, groupF]);
244+
245+
const [groups] = await syncService.sync(true, true);
246+
247+
const allUsers = new Set(["userA", "userB", "userC", "userD", "userE", "userF"]);
248+
const a = groups.find((g) => g.name === "GroupA");
249+
const b = groups.find((g) => g.name === "GroupB");
250+
const c = groups.find((g) => g.name === "GroupC");
251+
const d = groups.find((g) => g.name === "GroupD");
252+
const e = groups.find((g) => g.name === "GroupE");
253+
const f = groups.find((g) => g.name === "GroupF");
254+
255+
// A can reach all groups, so it gets all users
256+
expect(a.userMemberExternalIds).toEqual(allUsers);
257+
// B reaches D, E, and through cycles reaches everything
258+
expect(b.userMemberExternalIds).toEqual(allUsers);
259+
// C reaches E (which cycles back to C) and F
260+
expect(c.userMemberExternalIds).toEqual(new Set(["userC", "userE", "userF"]));
261+
// D cycles to A, which reaches everything
262+
expect(d.userMemberExternalIds).toEqual(allUsers);
263+
// E cycles to C, picking up C's descendants
264+
expect(e.userMemberExternalIds).toEqual(new Set(["userC", "userE", "userF"]));
265+
// F is a leaf
266+
expect(f.userMemberExternalIds).toEqual(new Set(["userF"]));
267+
});
268+
});
137269
});

src/services/sync.service.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,27 @@ export class SyncService {
196196
return users == null ? null : users.filter((u) => u.email?.length <= 256);
197197
}
198198

199-
private flattenUsersToGroups(levelGroups: GroupEntry[], allGroups: GroupEntry[]): Set<string> {
199+
private flattenUsersToGroups(
200+
levelGroups: GroupEntry[],
201+
allGroups: GroupEntry[],
202+
visitedGroups?: Set<string>,
203+
): Set<string> {
200204
let allUsers = new Set<string>();
201205
if (allGroups == null) {
202206
return allUsers;
203207
}
208+
204209
for (const group of levelGroups) {
210+
const visited = visitedGroups ?? new Set<string>();
211+
212+
if (visited.has(group.referenceId)) {
213+
continue;
214+
}
215+
216+
visited.add(group.referenceId);
217+
205218
const childGroups = allGroups.filter((g) => group.groupMemberReferenceIds.has(g.referenceId));
206-
const childUsers = this.flattenUsersToGroups(childGroups, allGroups);
219+
const childUsers = this.flattenUsersToGroups(childGroups, allGroups, visited);
207220
childUsers.forEach((id) => group.userMemberExternalIds.add(id));
208221
allUsers = new Set([...allUsers, ...group.userMemberExternalIds]);
209222
}

0 commit comments

Comments
 (0)