@@ -64,8 +64,8 @@ function createPreferencePort(): IAuthPreferenceStore {
6464}
6565
6666const identityCipher : IAuthTokenCipher = {
67- encrypt : ( plaintext ) => plaintext ,
68- decrypt : ( encrypted ) => encrypted ,
67+ encrypt : ( plaintext ) => Promise . resolve ( plaintext ) ,
68+ decrypt : ( encrypted ) => Promise . resolve ( encrypted ) ,
6969} ;
7070
7171const mockLogger : RootLogger = {
@@ -413,6 +413,52 @@ describe("AuthService", () => {
413413 }
414414 } ) ;
415415
416+ it ( "dedupes concurrent refreshes into a single token request" , async ( ) => {
417+ oauthFlow . startFlow . mockResolvedValue (
418+ mockTokenResponse ( {
419+ accessToken : "initial-access-token" ,
420+ refreshToken : "initial-refresh-token" ,
421+ } ) ,
422+ ) ;
423+ stubAuthFetch ( ) ;
424+
425+ // Keep the refresh in flight so both callers overlap; if the refresh
426+ // isn't deduped, the rotating refresh token is spent twice.
427+ let resolveRefresh ! : ( value : unknown ) => void ;
428+ oauthFlow . refreshToken . mockReturnValue (
429+ new Promise ( ( resolve ) => {
430+ resolveRefresh = resolve ;
431+ } ) ,
432+ ) ;
433+
434+ await service . initialize ( ) ;
435+ await service . login ( "us" ) ;
436+
437+ // Two forced refreshes fired in the same tick. The dedup guard must close
438+ // synchronously — resolving the stored session now awaits decryption, so
439+ // if that await sat between the guard and the refreshPromise assignment,
440+ // both callers would slip through and fire their own request.
441+ oauthFlow . refreshToken . mockClear ( ) ;
442+ const both = Promise . all ( [
443+ service . refreshAccessToken ( ) ,
444+ service . refreshAccessToken ( ) ,
445+ ] ) ;
446+
447+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
448+ expect ( oauthFlow . refreshToken ) . toHaveBeenCalledTimes ( 1 ) ;
449+
450+ resolveRefresh (
451+ mockTokenResponse ( {
452+ accessToken : "refreshed-access-token" ,
453+ refreshToken : "refreshed-refresh-token" ,
454+ } ) ,
455+ ) ;
456+ const [ a , b ] = await both ;
457+ expect ( a . accessToken ) . toBe ( "refreshed-access-token" ) ;
458+ expect ( b . accessToken ) . toBe ( "refreshed-access-token" ) ;
459+ expect ( oauthFlow . refreshToken ) . toHaveBeenCalledTimes ( 1 ) ;
460+ } ) ;
461+
416462 it ( "forces a token refresh when explicitly requested" , async ( ) => {
417463 oauthFlow . startFlow . mockResolvedValue (
418464 mockTokenResponse ( {
@@ -500,6 +546,141 @@ describe("AuthService", () => {
500546 } ) ;
501547 } ) ;
502548
549+ it ( "keeps the prior selection committed when persisting a new selection fails" , async ( ) => {
550+ const orgs = {
551+ "org-1" : {
552+ name : "Org 1" ,
553+ projects : [
554+ { id : 42 , name : "Project 42" } ,
555+ { id : 84 , name : "Project 84" } ,
556+ ] ,
557+ } ,
558+ } ;
559+ oauthFlow . startFlow . mockResolvedValue (
560+ mockTokenResponse ( {
561+ accessToken : "initial-access-token" ,
562+ refreshToken : "initial-refresh-token" ,
563+ } ) ,
564+ ) ;
565+ stubAuthFetch ( { orgs } ) ;
566+
567+ // Encrypts fine during login, then rejects so the selectProject persist
568+ // fails mid-flow (mirrors the browser cipher's Web Crypto rejecting).
569+ let failEncrypt = false ;
570+ const flakyCipher : IAuthTokenCipher = {
571+ encrypt : ( plaintext ) =>
572+ failEncrypt
573+ ? Promise . reject ( new Error ( "encryption unavailable" ) )
574+ : Promise . resolve ( plaintext ) ,
575+ decrypt : ( encrypted ) => Promise . resolve ( encrypted ) ,
576+ } ;
577+ service = new AuthService (
578+ preferencePort ,
579+ sessionPort ,
580+ oauthFlow as unknown as IAuthOAuthFlowService ,
581+ connectivity ,
582+ flakyCipher ,
583+ mockPowerManager as unknown as IPowerManager ,
584+ mockLogger ,
585+ null ,
586+ ) ;
587+
588+ // Initialize once while the session store is empty so login/selectProject
589+ // don't later trigger the stored-session restore path.
590+ service . init ( ) ;
591+ await service . initialize ( ) ;
592+
593+ await service . login ( "us" ) ;
594+ const priorProjectId = service . getState ( ) . currentProjectId ;
595+ expect ( priorProjectId ) . toBe ( 42 ) ;
596+
597+ failEncrypt = true ;
598+ await expect ( service . selectProject ( 84 ) ) . rejects . toThrow (
599+ "encryption unavailable" ,
600+ ) ;
601+
602+ // A failed persist must not commit the new project anywhere: published
603+ // state, the stored session, and the saved preference all stay on the
604+ // prior selection (the preference is the tell — the buggy order saved it
605+ // to 84 before the encrypt rejected).
606+ expect ( service . getState ( ) . currentProjectId ) . toBe ( priorProjectId ) ;
607+ expect ( sessionPort . getCurrent ( ) ?. selectedProjectId ) . toBe ( priorProjectId ) ;
608+ expect ( preferencePort . get ( "user-1" , "us" ) ?. lastSelectedProjectId ) . toBe (
609+ priorProjectId ,
610+ ) ;
611+ } ) ;
612+
613+ it ( "keeps overlapping selections consistent so the latest one wins" , async ( ) => {
614+ const orgs = {
615+ "org-1" : {
616+ name : "Org 1" ,
617+ projects : [
618+ { id : 42 , name : "Project 42" } ,
619+ { id : 84 , name : "Project 84" } ,
620+ { id : 99 , name : "Project 99" } ,
621+ ] ,
622+ } ,
623+ } ;
624+ oauthFlow . startFlow . mockResolvedValue (
625+ mockTokenResponse ( {
626+ accessToken : "initial-access-token" ,
627+ refreshToken : "initial-refresh-token" ,
628+ } ) ,
629+ ) ;
630+ stubAuthFetch ( { orgs } ) ;
631+
632+ // Encryption is gated so overlapping selection commits stay in flight and
633+ // can be resolved out of order below.
634+ let deferEncrypt = false ;
635+ const pending : Array < ( ) => void > = [ ] ;
636+ const gatedCipher : IAuthTokenCipher = {
637+ encrypt : ( plaintext ) =>
638+ deferEncrypt
639+ ? new Promise < string > ( ( resolve ) => {
640+ pending . push ( ( ) => resolve ( plaintext ) ) ;
641+ } )
642+ : Promise . resolve ( plaintext ) ,
643+ decrypt : ( encrypted ) => Promise . resolve ( encrypted ) ,
644+ } ;
645+ service = new AuthService (
646+ preferencePort ,
647+ sessionPort ,
648+ oauthFlow as unknown as IAuthOAuthFlowService ,
649+ connectivity ,
650+ gatedCipher ,
651+ mockPowerManager as unknown as IPowerManager ,
652+ mockLogger ,
653+ null ,
654+ ) ;
655+ service . init ( ) ;
656+ await service . initialize ( ) ;
657+ await service . login ( "us" ) ;
658+ expect ( service . getState ( ) . currentProjectId ) . toBe ( 42 ) ;
659+
660+ deferEncrypt = true ;
661+ // Two overlapping selections: 84 first, then 99 (the latest intent).
662+ const first = service . selectProject ( 84 ) ;
663+ const second = service . selectProject ( 99 ) ;
664+
665+ // Drain pending encryptions newest-first each round to surface any
666+ // out-of-order completion, until both commits settle. Serialized commits
667+ // only expose one pending encryption at a time; unserialized ones would
668+ // let the stale 84 commit land last.
669+ const flush = ( ) => new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
670+ await flush ( ) ;
671+ while ( pending . length > 0 ) {
672+ for ( const resolve of pending . splice ( 0 ) . reverse ( ) ) resolve ( ) ;
673+ await flush ( ) ;
674+ }
675+ await Promise . all ( [ first , second ] ) ;
676+
677+ // The latest selection wins everywhere — no stale overwrite and no split
678+ // between the in-memory session (getState), stored session, and preference.
679+ expect ( service . getState ( ) . currentProjectId ) . toBe ( 99 ) ;
680+ expect ( sessionPort . getCurrent ( ) ?. selectedProjectId ) . toBe ( 99 ) ;
681+ expect ( preferencePort . get ( "user-1" , "us" ) ?. lastSelectedProjectId ) . toBe ( 99 ) ;
682+ } ) ;
683+
503684 it ( "restores the selected project after app restart while logged out" , async ( ) => {
504685 const orgs = {
505686 "org-1" : {
0 commit comments