@@ -51,7 +51,7 @@ const getErrorMessage = () => {
5151describe ( "sso" , ( ) => {
5252 describe ( "authenticate" , ( ) => {
5353 beforeEach ( ( ) => {
54- jest . clearAllMocks ( )
54+ jest . resetAllMocks ( )
5555 testEnv . singleTenant ( )
5656 nock . cleanAll ( )
5757 mockInvite . getExistingInvites . mockResolvedValue ( [ ] )
@@ -466,15 +466,25 @@ describe("sso", () => {
466466 mockInvite . deleteCode . mockResolvedValueOnce ( undefined )
467467 } )
468468
469- it ( "reconciles the invite without requiring a verified email, deletes it, and fires the accepted event" , async ( ) => {
469+ it ( "rejects the login rather than creating an account when the email is unverified" , async ( ) => {
470+ await sso . authenticate ( details , false , mockDone , mockSaveUser )
471+
472+ expect ( mockSaveUser ) . not . toHaveBeenCalled ( )
473+ expect ( mockInvite . deleteCode ) . not . toHaveBeenCalled ( )
474+ expect ( events . user . inviteAccepted ) . not . toHaveBeenCalled ( )
475+ expect ( mockDone . mock . calls . length ) . toBe ( 1 )
476+ expect ( getErrorMessage ( ) ) . toContain (
477+ "Email verification is required to accept this invite."
478+ )
479+ } )
480+
481+ it ( "reconciles the invite when the email is verified, deletes it, and fires the accepted event" , async ( ) => {
482+ details . emailVerified = true
470483 const ssoUser = structures . users . ssoUser ( { details } )
471484 mockSaveUser . mockReturnValueOnce ( ssoUser )
472485
473486 await sso . authenticate ( details , false , mockDone , mockSaveUser )
474487
475- // the invite is matched purely on email - no account-linking lookup happens
476- expect ( users . getGlobalUserByEmail ) . not . toHaveBeenCalled ( )
477-
478488 expect ( mockSaveUser ) . toHaveBeenCalledWith (
479489 expect . objectContaining ( {
480490 _id : "us_" + details . userId ,
@@ -493,16 +503,61 @@ describe("sso", () => {
493503 expect ( mockDone ) . toHaveBeenCalledWith ( null , ssoUser )
494504 } )
495505
496- it ( "reconciles the invite even when a local account would otherwise be required " , async ( ) => {
506+ it ( "reconciles a non-admin invite when unverified email linking is explicitly allowed " , async ( ) => {
497507 const ssoUser = structures . users . ssoUser ( { details } )
498508 mockSaveUser . mockReturnValueOnce ( ssoUser )
499509
500- await sso . authenticate ( details , true , mockDone , mockSaveUser )
510+ await sso . authenticate ( details , true , mockDone , mockSaveUser , true )
511+
512+ expect ( mockDone ) . toHaveBeenCalledWith ( null , ssoUser )
513+ } )
514+
515+ it ( "reconciles an eligible invite when an admin invite for the same email appears first" , async ( ) => {
516+ const adminInvite : InviteWithCode = {
517+ code : structures . uuid ( ) ,
518+ email : details . email ! ,
519+ info : {
520+ tenantId : context . getTenantId ( ) ,
521+ admin : { global : true } ,
522+ } ,
523+ }
524+ mockInvite . getExistingInvites . mockReset ( )
525+ mockInvite . getExistingInvites . mockResolvedValueOnce ( [
526+ adminInvite ,
527+ invite ,
528+ ] )
529+ const ssoUser = structures . users . ssoUser ( { details } )
530+ mockSaveUser . mockReturnValueOnce ( ssoUser )
531+
532+ await sso . authenticate ( details , true , mockDone , mockSaveUser , true )
501533
534+ expect ( mockInvite . getCode ) . toHaveBeenCalledWith (
535+ invite . code ,
536+ invite . info . tenantId
537+ )
538+ expect ( mockInvite . deleteCode ) . toHaveBeenCalledWith (
539+ invite . code ,
540+ invite . info . tenantId
541+ )
502542 expect ( mockDone ) . toHaveBeenCalledWith ( null , ssoUser )
503543 } )
504544
545+ it ( "rejects an unverified login for an admin invite even when unverified email linking is allowed" , async ( ) => {
546+ invite . info . admin = { global : true }
547+
548+ await sso . authenticate ( details , false , mockDone , mockSaveUser , true )
549+
550+ expect ( mockSaveUser ) . not . toHaveBeenCalled ( )
551+ expect ( mockInvite . deleteCode ) . not . toHaveBeenCalled ( )
552+ expect ( events . user . inviteAccepted ) . not . toHaveBeenCalled ( )
553+ expect ( mockDone . mock . calls . length ) . toBe ( 1 )
554+ expect ( getErrorMessage ( ) ) . toContain (
555+ "Email verification is required to accept this invite."
556+ )
557+ } )
558+
505559 it ( "reuses the account when the same identity's own concurrent login already claimed the invite" , async ( ) => {
560+ details . emailVerified = true
506561 // simulates a second, racing request for this exact identity
507562 // (e.g. a double-submitted login) losing the lock race: the
508563 // winner already consumed the invite and saved the account for
@@ -524,8 +579,6 @@ describe("sso", () => {
524579
525580 await sso . authenticate ( details , false , mockDone , mockSaveUser )
526581
527- // reused via the deterministic id, never by email match
528- expect ( users . getGlobalUserByEmail ) . not . toHaveBeenCalled ( )
529582 // the winner's account is reused - no second document is created
530583 expect ( mockSaveUser ) . toHaveBeenCalledWith (
531584 expect . objectContaining ( { _id : existingUser . _id } ) ,
@@ -537,6 +590,7 @@ describe("sso", () => {
537590 } )
538591
539592 it ( "fails closed when the invite can no longer be validated and no concurrent claim for this identity can be confirmed" , async ( ) => {
593+ details . emailVerified = true
540594 // covers expired/revoked invites and failed reads alike - none of
541595 // these are a positively identified concurrent claim, so this must
542596 // not fall back to linking by email or creating a fresh account
@@ -548,7 +602,6 @@ describe("sso", () => {
548602
549603 await sso . authenticate ( details , false , mockDone , mockSaveUser )
550604
551- expect ( users . getGlobalUserByEmail ) . not . toHaveBeenCalled ( )
552605 expect ( mockSaveUser ) . not . toHaveBeenCalled ( )
553606 expect ( mockInvite . deleteCode ) . not . toHaveBeenCalled ( )
554607 expect ( events . user . inviteAccepted ) . not . toHaveBeenCalled ( )
0 commit comments