@@ -576,4 +576,229 @@ describe('authorize (device flow)', () => {
576576 `[AuthError: ERR_A_16: Unexpected error requesting authorization grant]` ,
577577 ) ;
578578 } ) ;
579+
580+ describe ( 'AbortController integration' , ( ) => {
581+ // Common test constants
582+ const baseUrl = 'https://glean.example.com' ;
583+ const issuer = 'https://auth.example.com' ;
584+ const clientId = 'client-123' ;
585+ const deviceAuthorizationEndpoint = 'https://auth.example.com/device' ;
586+ const tokenEndpoint = 'https://auth.example.com/token' ;
587+ const deviceCode = 'device-code-abc' ;
588+ const userCode = 'user-code-xyz' ;
589+ const verificationUri = 'https://auth.example.com/verify' ;
590+ const interval = 5 ;
591+ const accessToken = 'access-token-123' ;
592+ const refreshToken = 'refresh-token-456' ;
593+ const expiresIn = 3600 ;
594+
595+ // Helper function to mock readline interface
596+ async function mockReadlineInterface (
597+ behavior :
598+ | 'user-opened-browser-manually'
599+ | 'user-pressed-enter' = 'user-opened-browser-manually' ,
600+ ) {
601+ const mockClose = vi . fn ( ) ;
602+ const mockOnce = vi
603+ . fn ( )
604+ . mockImplementation ( ( _event : string , cb : ( ) => void ) => {
605+ if ( behavior === 'user-pressed-enter' ) {
606+ setTimeout ( cb , 0 ) ;
607+ }
608+ // For 'user-opened-browser-manually', don't call cb - simulate user opening browser manually
609+ } ) ;
610+
611+ vi . mocked ( await import ( 'node:readline' ) ) . default . createInterface = vi
612+ . fn ( )
613+ . mockReturnValue ( {
614+ once : mockOnce ,
615+ close : mockClose ,
616+ } ) ;
617+
618+ return { mockClose, mockOnce } ;
619+ }
620+
621+ // Helper function to set up common OAuth metadata endpoints
622+ function setupOAuthMetadataEndpoints ( ) {
623+ return [
624+ http . get ( `${ baseUrl } /.well-known/oauth-protected-resource` , ( ) =>
625+ HttpResponse . json ( {
626+ authorization_servers : [ issuer ] ,
627+ glean_device_flow_client_id : clientId ,
628+ } ) ,
629+ ) ,
630+ http . get ( `${ issuer } /.well-known/openid-configuration` , ( ) =>
631+ HttpResponse . json ( {
632+ device_authorization_endpoint : deviceAuthorizationEndpoint ,
633+ token_endpoint : tokenEndpoint ,
634+ } ) ,
635+ ) ,
636+ http . post ( deviceAuthorizationEndpoint , ( ) =>
637+ HttpResponse . json ( {
638+ device_code : deviceCode ,
639+ user_code : userCode ,
640+ verification_uri : verificationUri ,
641+ expires_in : 600 ,
642+ interval,
643+ } ) ,
644+ ) ,
645+ ] ;
646+ }
647+
648+ it ( 'should abort readline interface when token polling succeeds' , async ( ) => {
649+ const { mockClose } = await mockReadlineInterface (
650+ 'user-opened-browser-manually' ,
651+ ) ;
652+
653+ // Mock OAuth endpoints with immediate success
654+ server . use (
655+ ...setupOAuthMetadataEndpoints ( ) ,
656+ http . post ( tokenEndpoint , async ( { request } ) => {
657+ const body = await request . text ( ) ;
658+ if ( body . includes ( `device_code=${ deviceCode } ` ) ) {
659+ return HttpResponse . json ( {
660+ token_type : 'Bearer' ,
661+ access_token : accessToken ,
662+ refresh_token : refreshToken ,
663+ expires_in : expiresIn ,
664+ } ) ;
665+ }
666+ return HttpResponse . json ( {
667+ error : 'authorization_pending' ,
668+ error_description : 'pending' ,
669+ } ) ;
670+ } ) ,
671+ ) ;
672+
673+ // Act
674+ const resultPromise = forceAuthorize ( ) ;
675+ await vi . runAllTimersAsync ( ) ;
676+ const tokens = await resultPromise ;
677+
678+ // Assert
679+ expect ( tokens ) . not . toBeNull ( ) ;
680+ expect ( tokens ?. accessToken ) . toBe ( accessToken ) ;
681+ expect ( mockClose ) . toHaveBeenCalled ( ) ;
682+ } ) ;
683+
684+ it ( 'should not open browser when AbortController is aborted' , async ( ) => {
685+ const { mockClose } = await mockReadlineInterface (
686+ 'user-opened-browser-manually' ,
687+ ) ;
688+
689+ // Mock OAuth endpoints with immediate success
690+ server . use (
691+ ...setupOAuthMetadataEndpoints ( ) ,
692+ http . post ( tokenEndpoint , async ( { request } ) => {
693+ const body = await request . text ( ) ;
694+ if ( body . includes ( `device_code=${ deviceCode } ` ) ) {
695+ return HttpResponse . json ( {
696+ token_type : 'Bearer' ,
697+ access_token : accessToken ,
698+ refresh_token : refreshToken ,
699+ expires_in : expiresIn ,
700+ } ) ;
701+ }
702+ return HttpResponse . json ( {
703+ error : 'authorization_pending' ,
704+ error_description : 'pending' ,
705+ } ) ;
706+ } ) ,
707+ ) ;
708+
709+ // Act
710+ const open = ( await import ( 'open' ) ) . default ;
711+ const resultPromise = forceAuthorize ( ) ;
712+ await vi . runAllTimersAsync ( ) ;
713+ const tokens = await resultPromise ;
714+
715+ // Assert
716+ expect ( tokens ) . not . toBeNull ( ) ;
717+ expect ( tokens ?. accessToken ) . toBe ( accessToken ) ;
718+ expect ( mockClose ) . toHaveBeenCalled ( ) ;
719+ // The browser should not be opened because the AbortController aborted
720+ // before the user pressed Enter (since token polling succeeded immediately)
721+ expect ( open ) . not . toHaveBeenCalled ( ) ;
722+ } ) ;
723+
724+ it ( 'should still open browser when user presses Enter before token polling succeeds' , async ( ) => {
725+ let pollCount = 0 ;
726+ const { mockClose } = await mockReadlineInterface ( 'user-pressed-enter' ) ;
727+
728+ // Mock OAuth endpoints with delayed success
729+ server . use (
730+ ...setupOAuthMetadataEndpoints ( ) ,
731+ http . post ( tokenEndpoint , async ( { request } ) => {
732+ const body = await request . text ( ) ;
733+ if ( body . includes ( `device_code=${ deviceCode } ` ) ) {
734+ pollCount ++ ;
735+ if ( pollCount >= 3 ) {
736+ return HttpResponse . json ( {
737+ token_type : 'Bearer' ,
738+ access_token : accessToken ,
739+ refresh_token : refreshToken ,
740+ expires_in : expiresIn ,
741+ } ) ;
742+ }
743+ return HttpResponse . json ( {
744+ error : 'authorization_pending' ,
745+ error_description : 'pending' ,
746+ } ) ;
747+ }
748+ return HttpResponse . json ( {
749+ error : 'authorization_pending' ,
750+ error_description : 'pending' ,
751+ } ) ;
752+ } ) ,
753+ ) ;
754+
755+ // Act
756+ const open = ( await import ( 'open' ) ) . default ;
757+ const resultPromise = forceAuthorize ( ) ;
758+ await vi . runAllTimersAsync ( ) ;
759+ const tokens = await resultPromise ;
760+
761+ // Assert
762+ expect ( tokens ) . not . toBeNull ( ) ;
763+ expect ( tokens ?. accessToken ) . toBe ( accessToken ) ;
764+ expect ( mockClose ) . toHaveBeenCalled ( ) ;
765+ // The browser should be opened because the user pressed Enter before token polling succeeded
766+ expect ( open ) . toHaveBeenCalledWith ( verificationUri ) ;
767+ } ) ;
768+
769+ it ( 'should clean up readline interface on error' , async ( ) => {
770+ const { mockClose } = await mockReadlineInterface (
771+ 'user-opened-browser-manually' ,
772+ ) ;
773+
774+ // Mock OAuth endpoints with error in token polling
775+ server . use (
776+ ...setupOAuthMetadataEndpoints ( ) ,
777+ http . post ( tokenEndpoint , async ( { request } ) => {
778+ const body = await request . text ( ) ;
779+ if ( body . includes ( `device_code=${ deviceCode } ` ) ) {
780+ return HttpResponse . json (
781+ {
782+ error : 'invalid_grant' ,
783+ error_description : 'The device code is invalid' ,
784+ } ,
785+ { status : 400 } ,
786+ ) ;
787+ }
788+ return HttpResponse . json ( {
789+ error : 'authorization_pending' ,
790+ error_description : 'pending' ,
791+ } ) ;
792+ } ) ,
793+ ) ;
794+
795+ // Act & Assert
796+ await expect ( forceAuthorize ( ) ) . rejects . toThrowErrorMatchingInlineSnapshot (
797+ `[AuthError: ERR_A_16: Unexpected error requesting authorization grant]` ,
798+ ) ;
799+
800+ // Assert that readline interface was cleaned up even on error
801+ expect ( mockClose ) . toHaveBeenCalled ( ) ;
802+ } ) ;
803+ } ) ;
579804} ) ;
0 commit comments