@@ -292,8 +292,14 @@ describe('createWorkspaceInvitation', () => {
292292 it . each ( [ 'admin' , 'owner' ] as const ) (
293293 'leaves inherited access unchanged when ensuring access for an organization %s' ,
294294 async ( role ) => {
295- queueWhereResponses ( [ [ { id : 'user-2' , email : 'member@example.com' } ] , [ ] ] )
296- mockGetUserOrganization . mockResolvedValueOnce ( { organizationId : 'org-1' , role } )
295+ queueTableRows ( userTable , [ { id : 'user-2' , email : 'member@example.com' } ] )
296+ queueTableRows ( member , [ { role : 'owner' } ] )
297+ queueTableRows ( member , [ { role } ] )
298+ mockGetUserOrganization . mockResolvedValueOnce ( {
299+ organizationId : 'org-1' ,
300+ memberId : 'member-2' ,
301+ role,
302+ } )
297303
298304 const result = await createWorkspaceInvitation ( {
299305 context : makeContext ( [ 'ws-1' , 'ws-2' ] ) ,
@@ -318,41 +324,189 @@ describe('createWorkspaceInvitation', () => {
318324 }
319325 )
320326
321- it ( 'reports a promotion as updated without granting redundant workspace permissions' , async ( ) => {
327+ it . each ( [ 'member' , 'admin' ] ) (
328+ 'reports a current member promotion correctly after initially observing %s' ,
329+ async ( observedRole ) => {
330+ queueTableRows ( userTable , [ { id : 'user-2' } ] )
331+ queueTableRows ( member , [ { role : 'owner' } ] )
332+ queueTableRows ( member , [ { role : 'member' } ] )
333+ mockGetUserOrganization . mockResolvedValueOnce ( {
334+ organizationId : 'org-1' ,
335+ memberId : 'member-2' ,
336+ role : observedRole ,
337+ } )
338+
339+ const result = await createWorkspaceInvitation ( {
340+ context : makeContext ( [ 'ws-1' , 'ws-2' ] ) ,
341+ email : 'member@example.com' ,
342+ permission : 'write' ,
343+ membership : 'admin' ,
344+ existingAccessPolicy : 'ensure-at-least' ,
345+ request,
346+ } )
347+
348+ expect ( result ) . toMatchObject ( {
349+ workspaceIds : [ ] ,
350+ instantAdd : true ,
351+ outcome : 'updated' ,
352+ membershipIntent : 'internal' ,
353+ } )
354+ expect ( dbChainMockFns . update ) . toHaveBeenCalledExactlyOnceWith ( member )
355+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith ( { role : 'admin' } )
356+ expect ( auditMockFns . mockRecordAudit ) . toHaveBeenCalledExactlyOnceWith (
357+ expect . objectContaining ( {
358+ action : 'org_member.role_changed' ,
359+ metadata : expect . objectContaining ( { previousRole : 'member' , newRole : 'admin' } ) ,
360+ } )
361+ )
362+ expect ( mockGrantWorkspaceAccessDirectly ) . not . toHaveBeenCalled ( )
363+ expect ( mockCreatePendingInvitation ) . not . toHaveBeenCalled ( )
364+ expect ( mockSendInvitationEmail ) . not . toHaveBeenCalled ( )
365+ }
366+ )
367+
368+ it ( 'reconciles workspace access when an inherited admin was demoted before the locked check' , async ( ) => {
322369 queueTableRows ( userTable , [ { id : 'user-2' } ] )
323- queueTableRows ( member , [ { role : 'owner ' } ] )
370+ queueTableRows ( member , [ { role : 'member ' } ] )
324371 queueTableRows ( member , [ { role : 'member' } ] )
325372 mockGetUserOrganization . mockResolvedValueOnce ( {
326373 organizationId : 'org-1' ,
327374 memberId : 'member-2' ,
328- role : 'member ' ,
375+ role : 'admin ' ,
329376 } )
330377
331378 const result = await createWorkspaceInvitation ( {
332- context : makeContext ( [ 'ws-1' , 'ws-2' ] ) ,
379+ context : makeContext ( ) ,
333380 email : 'member@example.com' ,
381+ membership : 'member' ,
334382 permission : 'write' ,
335- membership : 'admin' ,
336383 existingAccessPolicy : 'ensure-at-least' ,
337- request,
338384 } )
339385
340386 expect ( result ) . toMatchObject ( {
341- workspaceIds : [ ] ,
387+ outcome : 'added' ,
388+ workspaceIds : [ 'ws-1' ] ,
342389 instantAdd : true ,
343- outcome : 'updated' ,
344- membershipIntent : 'internal' ,
345390 } )
346- expect ( dbChainMockFns . update ) . toHaveBeenCalledExactlyOnceWith ( member )
347- expect ( dbChainMockFns . set ) . toHaveBeenCalledWith ( { role : 'admin' } )
348- expect ( auditMockFns . mockRecordAudit ) . toHaveBeenCalledExactlyOnceWith (
349- expect . objectContaining ( { action : 'org_member.role_changed' } )
391+ expect ( mockAcquireInvitationMutationLocks ) . toHaveBeenCalledExactlyOnceWith ( expect . anything ( ) , {
392+ invitationIds : [ ] ,
393+ workspaceIds : [ 'ws-1' ] ,
394+ } )
395+ expect ( mockAcquireInvitationMutationLocks . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
396+ mockAcquireOrganizationUserMutationLocks . mock . invocationCallOrder [ 0 ]
397+ )
398+ expect ( mockAcquireOrganizationUserMutationLocks . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
399+ dbChainMockFns . for . mock . invocationCallOrder [ 0 ]
400+ )
401+ expect ( mockGetEffectiveWorkspacePermission ) . toHaveBeenCalledExactlyOnceWith (
402+ 'user-1' ,
403+ expect . objectContaining ( { id : 'ws-1' , organizationId : 'org-1' } ) ,
404+ expect . anything ( )
405+ )
406+ expect ( mockGrantWorkspaceAccessDirectly ) . toHaveBeenCalledExactlyOnceWith (
407+ expect . objectContaining ( { userId : 'user-2' , permission : 'write' } )
350408 )
409+ expect ( dbChainMockFns . update ) . not . toHaveBeenCalled ( )
410+ expect ( mockCreatePendingInvitation ) . not . toHaveBeenCalled ( )
411+ } )
412+
413+ it ( 'lets workspace admins preserve current inherited access without organization-admin authority' , async ( ) => {
414+ queueTableRows ( userTable , [ { id : 'user-2' } ] )
415+ queueTableRows ( member , [ { role : 'member' } ] )
416+ queueTableRows ( member , [ { role : 'admin' } ] )
417+ mockGetUserOrganization . mockResolvedValueOnce ( {
418+ organizationId : 'org-1' ,
419+ memberId : 'member-2' ,
420+ role : 'admin' ,
421+ } )
422+
423+ const result = await createWorkspaceInvitation ( {
424+ context : makeContext ( ) ,
425+ email : 'member@example.com' ,
426+ membership : 'member' ,
427+ existingAccessPolicy : 'ensure-at-least' ,
428+ } )
429+
430+ expect ( result ) . toMatchObject ( { outcome : 'unchanged' , workspaceIds : [ ] } )
431+ expect ( mockGetEffectiveWorkspacePermission ) . toHaveBeenCalled ( )
432+ expect ( dbChainMockFns . update ) . not . toHaveBeenCalled ( )
351433 expect ( mockGrantWorkspaceAccessDirectly ) . not . toHaveBeenCalled ( )
434+ expect ( mockSendInvitationEmail ) . not . toHaveBeenCalled ( )
435+ } )
436+
437+ it ( 'excludes workspaces already covered when the direct grant observes a concurrent promotion' , async ( ) => {
438+ queueTableRows ( userTable , [ { id : 'user-2' } ] )
439+ mockGetUserOrganization . mockResolvedValueOnce ( {
440+ organizationId : 'org-1' ,
441+ memberId : 'member-2' ,
442+ role : 'member' ,
443+ } )
444+ mockGrantWorkspaceAccessDirectly . mockResolvedValueOnce ( {
445+ outcome : 'unchanged' ,
446+ permission : 'admin' ,
447+ } )
448+
449+ const result = await createWorkspaceInvitation ( {
450+ context : makeContext ( ) ,
451+ email : 'member@example.com' ,
452+ existingAccessPolicy : 'ensure-at-least' ,
453+ } )
454+
455+ expect ( result ) . toMatchObject ( { outcome : 'unchanged' , workspaceIds : [ ] , instantAdd : true } )
456+ expect ( mockGrantWorkspaceAccessDirectly ) . toHaveBeenCalledOnce ( )
352457 expect ( mockCreatePendingInvitation ) . not . toHaveBeenCalled ( )
353458 expect ( mockSendInvitationEmail ) . not . toHaveBeenCalled ( )
354459 } )
355460
461+ it ( 'rejects inherited access reconciliation when the workspace changed organizations' , async ( ) => {
462+ queueTableRows ( userTable , [ { id : 'user-2' } ] )
463+ queueTableRows ( member , [ { role : 'owner' } ] )
464+ queueTableRows ( member , [ { role : 'admin' } ] )
465+ mockGetUserOrganization . mockResolvedValueOnce ( {
466+ organizationId : 'org-1' ,
467+ memberId : 'member-2' ,
468+ role : 'admin' ,
469+ } )
470+ mockGetWorkspaceWithOwner . mockResolvedValueOnce ( makeTarget ( 'ws-1' , 'org-2' ) . workspaceDetails )
471+
472+ await expect (
473+ createWorkspaceInvitation ( {
474+ context : makeContext ( ) ,
475+ email : 'member@example.com' ,
476+ existingAccessPolicy : 'ensure-at-least' ,
477+ } )
478+ ) . rejects . toMatchObject ( { status : 409 } )
479+
480+ expect ( dbChainMockFns . update ) . not . toHaveBeenCalled ( )
481+ expect ( mockGrantWorkspaceAccessDirectly ) . not . toHaveBeenCalled ( )
482+ expect ( mockSendInvitationEmail ) . not . toHaveBeenCalled ( )
483+ expect ( auditMockFns . mockRecordAudit ) . not . toHaveBeenCalled ( )
484+ } )
485+
486+ it ( 'rejects an inherited-access no-op when the inviter lost workspace admin access' , async ( ) => {
487+ queueTableRows ( userTable , [ { id : 'user-2' } ] )
488+ queueTableRows ( member , [ { role : 'member' } ] )
489+ queueTableRows ( member , [ { role : 'admin' } ] )
490+ mockGetUserOrganization . mockResolvedValueOnce ( {
491+ organizationId : 'org-1' ,
492+ memberId : 'member-2' ,
493+ role : 'admin' ,
494+ } )
495+ mockGetEffectiveWorkspacePermission . mockResolvedValueOnce ( 'read' )
496+
497+ await expect (
498+ createWorkspaceInvitation ( {
499+ context : makeContext ( ) ,
500+ email : 'member@example.com' ,
501+ existingAccessPolicy : 'ensure-at-least' ,
502+ } )
503+ ) . rejects . toMatchObject ( { status : 409 } )
504+
505+ expect ( mockGrantWorkspaceAccessDirectly ) . not . toHaveBeenCalled ( )
506+ expect ( mockSendInvitationEmail ) . not . toHaveBeenCalled ( )
507+ expect ( auditMockFns . mockRecordAudit ) . not . toHaveBeenCalled ( )
508+ } )
509+
356510 it . each ( [ 'admin' , 'owner' ] as const ) (
357511 'does not inherit workspace access from a different organization %s role' ,
358512 async ( role ) => {
0 commit comments