@@ -6,6 +6,8 @@ import { MessagingService } from "@/jslib/common/src/abstractions/messaging.serv
66import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest" ;
77import { ApiService } from "@/jslib/common/src/services/api.service" ;
88
9+ import { GroupEntry } from "@/src/models/groupEntry" ;
10+
911import { getSyncConfiguration } from "../../utils/openldap/config-fixtures" ;
1012import { DirectoryFactoryService } from "../abstractions/directory-factory.service" ;
1113import { 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} ) ;
0 commit comments