@@ -97,6 +97,7 @@ const makeProject = () => ({
9797 _id : 'project_1' ,
9898 name : 'Demo' ,
9999 jwtSecret : 'jwt_secret' ,
100+ siteUrl : 'http://localhost:5173' ,
100101 isAuthEnabled : true ,
101102 resources : { db : { isExternal : false } } ,
102103 collections : [
@@ -221,7 +222,7 @@ describe('public userAuth social auth', () => {
221222 } ) ;
222223
223224 test ( 'handleSocialAuthCallback rejects when GitHub returns no email' , async ( ) => {
224- redis . get . mockResolvedValueOnce ( JSON . stringify ( { projectId : 'project_1' , provider : 'github' } ) ) ;
225+ redis . get . mockResolvedValueOnce ( JSON . stringify ( { projectId : 'project_1' , provider : 'github' , callbackUrl : 'http://localhost:5173/auth/callback' } ) ) ;
225226 mockProjectFindByIdChain . lean . mockResolvedValueOnce ( makeProject ( ) ) ;
226227 global . fetch
227228 . mockResolvedValueOnce ( {
@@ -242,10 +243,9 @@ describe('public userAuth social auth', () => {
242243
243244 await controller . handleSocialAuthCallback ( req , res ) ;
244245
245- expect ( res . status ) . toHaveBeenCalledWith ( 422 ) ;
246- expect ( res . json ) . toHaveBeenCalledWith ( expect . objectContaining ( {
247- error : expect . stringContaining ( 'did not return an email address' ) ,
248- } ) ) ;
246+ // P2: errors now redirect to frontend instead of JSON
247+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'error=' ) ) ;
248+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'did+not+return+an+email' ) ) ;
249249 } ) ;
250250
251251 test ( 'handleSocialAuthCallback redirects after GitHub signup' , async ( ) => {
@@ -282,8 +282,9 @@ describe('public userAuth social auth', () => {
282282 } ) ) ;
283283 expect ( issueAuthTokens ) . toHaveBeenCalled ( ) ;
284284 expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( '/auth/callback?' ) ) ;
285- expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'token=issued_access_token' ) ) ;
286285 expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'rtCode=' ) ) ;
286+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( '#token=issued_access_token' ) ) ;
287+ expect ( res . redirect ) . not . toHaveBeenCalledWith ( expect . stringContaining ( '?token=issued_access_token' ) ) ;
287288 expect ( res . redirect ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'refreshToken=issued_refresh_token' ) ) ;
288289 } ) ;
289290
@@ -389,7 +390,7 @@ describe('public userAuth social auth', () => {
389390 } ) ;
390391
391392 test ( 'handleSocialAuthCallback rejects when Google returns no email' , async ( ) => {
392- redis . get . mockResolvedValueOnce ( JSON . stringify ( { projectId : 'project_1' , provider : 'google' } ) ) ;
393+ redis . get . mockResolvedValueOnce ( JSON . stringify ( { projectId : 'project_1' , provider : 'google' , callbackUrl : 'http://localhost:5173/auth/callback' } ) ) ;
393394 mockProjectFindByIdChain . lean . mockResolvedValueOnce ( makeProject ( ) ) ;
394395
395396 global . fetch
@@ -407,14 +408,13 @@ describe('public userAuth social auth', () => {
407408
408409 await controller . handleSocialAuthCallback ( req , res ) ;
409410
410- expect ( res . status ) . toHaveBeenCalledWith ( 422 ) ;
411- expect ( res . json ) . toHaveBeenCalledWith ( expect . objectContaining ( {
412- error : expect . stringContaining ( 'did not return an email address' ) ,
413- } ) ) ;
411+ // P2: errors now redirect to frontend instead of JSON
412+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'error=' ) ) ;
413+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'did+not+return+an+email' ) ) ;
414414 } ) ;
415415
416416 test ( 'handleSocialAuthCallback rejects invalid Google id_token audience' , async ( ) => {
417- redis . get . mockResolvedValueOnce ( JSON . stringify ( { projectId : 'project_1' , provider : 'google' } ) ) ;
417+ redis . get . mockResolvedValueOnce ( JSON . stringify ( { projectId : 'project_1' , provider : 'google' , callbackUrl : 'http://localhost:5173/auth/callback' } ) ) ;
418418 mockProjectFindByIdChain . lean . mockResolvedValueOnce ( makeProject ( ) ) ;
419419
420420 global . fetch
@@ -432,10 +432,9 @@ describe('public userAuth social auth', () => {
432432
433433 await controller . handleSocialAuthCallback ( req , res ) ;
434434
435- expect ( res . status ) . toHaveBeenCalledWith ( 500 ) ;
436- expect ( res . json ) . toHaveBeenCalledWith ( expect . objectContaining ( {
437- error : 'Google id_token audience mismatch' ,
438- } ) ) ;
435+ // P2: errors now redirect to frontend instead of JSON
436+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'error=' ) ) ;
437+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'audience' ) ) ;
439438 } ) ;
440439
441440 test ( 'exchangeSocialRefreshToken returns refresh token and deletes exchange code' , async ( ) => {
@@ -506,4 +505,107 @@ describe('public userAuth social auth', () => {
506505 message : 'Invalid refresh token exchange payload' ,
507506 } ) ;
508507 } ) ;
508+
509+ // P2: Provider error forwarding
510+ test ( 'handleSocialAuthCallback forwards provider error to frontend callback' , async ( ) => {
511+ redis . get . mockResolvedValueOnce ( JSON . stringify ( {
512+ projectId : 'project_1' ,
513+ provider : 'github' ,
514+ callbackUrl : 'http://localhost:5173/auth/callback' ,
515+ } ) ) ;
516+
517+ const req = makeReq ( {
518+ params : { provider : 'github' } ,
519+ query : {
520+ error : 'access_denied' ,
521+ error_description : 'The user denied the request' ,
522+ state : 'state_with_error' ,
523+ } ,
524+ } ) ;
525+ const res = makeRes ( ) ;
526+
527+ await controller . handleSocialAuthCallback ( req , res ) ;
528+
529+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'error=' ) ) ;
530+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'The+user+denied' ) ) ;
531+ } ) ;
532+
533+ // P1: Email collision when provider email is not verified
534+ test ( 'handleSocialAuthCallback rejects if email exists but provider email not verified' , async ( ) => {
535+ redis . get . mockResolvedValueOnce ( JSON . stringify ( {
536+ projectId : 'project_1' ,
537+ provider : 'github' ,
538+ callbackUrl : 'http://localhost:5173/auth/callback' ,
539+ } ) ) ;
540+ mockProjectFindByIdChain . lean . mockResolvedValueOnce ( makeProject ( ) ) ;
541+
542+ const existingUser = { _id : 'existing_user' , email : 'alice@example.com' } ;
543+
544+ // User not found by githubId, but found by email, then refetched after update
545+ mockUsersModel . findOne
546+ . mockResolvedValueOnce ( null ) // by githubId
547+ . mockResolvedValueOnce ( existingUser ) // by email
548+ . mockResolvedValueOnce ( { ...existingUser , githubId : '123' } ) ; // after updateOne
549+
550+ mockUsersModel . updateOne . mockResolvedValueOnce ( { modifiedCount : 1 } ) ;
551+
552+ // GitHub returns an email that IS verified at provider
553+ global . fetch
554+ . mockResolvedValueOnce ( {
555+ ok : true ,
556+ json : async ( ) => ( { access_token : 'github_access_token' } ) ,
557+ } )
558+ . mockResolvedValueOnce ( {
559+ ok : true ,
560+ json : async ( ) => ( { id : 123 , login : 'alice' , avatar_url : '' } ) ,
561+ } )
562+ . mockResolvedValueOnce ( {
563+ ok : true ,
564+ json : async ( ) => ( [ { email : 'alice@example.com' , verified : true , primary : true } ] ) ,
565+ } ) ;
566+
567+ const req = makeReq ( { params : { provider : 'github' } , query : { code : 'code_1' , state : 'state_1' } } ) ;
568+ const res = makeRes ( ) ;
569+
570+ await controller . handleSocialAuthCallback ( req , res ) ;
571+
572+ // With verified=true, should successfully link and redirect
573+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'rtCode=' ) ) ;
574+ expect ( mockUsersModel . updateOne ) . toHaveBeenCalled ( ) ;
575+ } ) ;
576+
577+ // P1: Email collision when provider email is NOT verified (rejection case)
578+ test ( 'handleSocialAuthCallback rejects existing email when provider email unverified' , async ( ) => {
579+ redis . get . mockResolvedValueOnce ( JSON . stringify ( {
580+ projectId : 'project_1' ,
581+ provider : 'google' ,
582+ callbackUrl : 'http://localhost:5173/auth/callback' ,
583+ } ) ) ;
584+ mockProjectFindByIdChain . lean . mockResolvedValueOnce ( makeProject ( ) ) ;
585+
586+ // User not found by googleId, but found by email
587+ mockUsersModel . findOne
588+ . mockResolvedValueOnce ( null ) // by googleId
589+ . mockResolvedValueOnce ( { _id : 'existing_user' , email : 'alice@example.com' } ) ; // by email
590+
591+ // Google returns unverified email (email_verified: false)
592+ global . fetch
593+ . mockResolvedValueOnce ( {
594+ ok : true ,
595+ json : async ( ) => ( { id_token : signGoogleIdToken ( { email : 'alice@example.com' , email_verified : false } ) } ) ,
596+ } )
597+ . mockResolvedValueOnce ( {
598+ ok : true ,
599+ json : async ( ) => ( { keys : [ GOOGLE_JWK ] } ) ,
600+ } ) ;
601+
602+ const req = makeReq ( { params : { provider : 'google' } , query : { code : 'code_1' , state : 'state_1' } } ) ;
603+ const res = makeRes ( ) ;
604+
605+ await controller . handleSocialAuthCallback ( req , res ) ;
606+
607+ // Should redirect with error because email exists but not verified for linking
608+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'error=' ) ) ;
609+ expect ( res . redirect ) . toHaveBeenCalledWith ( expect . stringContaining ( 'not+verified' ) ) ;
610+ } ) ;
509611} ) ;
0 commit comments