@@ -425,12 +425,20 @@ vi.mock("../lib/accounts.js", async () => {
425425 accessToken ?: string ;
426426 idToken ?: string ;
427427 } ) => {
428+ const tokenUtilsModule =
429+ tokenUtils as typeof import ( "../lib/auth/token-utils.js" ) ;
428430 const tokenAccountId = extractAccountIdMock ( accessToken ) ;
431+ const tokenEmail = tokenUtilsModule . sanitizeEmail (
432+ extractAccountEmailMock ( accessToken , idToken ) ,
433+ ) ;
434+ const sanitizedStoredEmail = tokenUtilsModule . sanitizeEmail ( storedEmail ) ;
429435 return {
430- accountId : (
431- tokenUtils as typeof import ( "../lib/auth/token-utils.js" )
432- ) . resolveRequestAccountId ( storedAccountId , source as never , tokenAccountId ) ,
433- email : extractAccountEmailMock ( accessToken , idToken ) ?? storedEmail ,
436+ accountId : tokenUtilsModule . resolveRequestAccountId (
437+ storedAccountId ,
438+ source as never ,
439+ tokenAccountId ,
440+ ) ,
441+ email : tokenEmail ?? sanitizedStoredEmail ,
434442 tokenAccountId,
435443 } ;
436444 } ,
@@ -440,7 +448,8 @@ vi.mock("../lib/accounts.js", async () => {
440448 formatAccountLabel : ( _account : unknown , index : number ) => `Account ${ index + 1 } ` ,
441449 formatCooldown : ( ) => null ,
442450 formatWaitTime : ( ms : number ) => `${ Math . round ( ms / 1000 ) } s` ,
443- sanitizeEmail : ( email : string ) => email ,
451+ sanitizeEmail : ( email : string | undefined ) =>
452+ typeof email === "string" ? email . trim ( ) . toLowerCase ( ) : undefined ,
444453 shouldUpdateAccountIdFromToken : ( ) => true ,
445454 parseRateLimitReason : ( ) => "unknown" ,
446455 lookupCodexCliTokensByEmail : vi . fn ( async ( ) => null ) ,
@@ -1606,6 +1615,140 @@ describe("OpenAIOAuthPlugin fetch handler", () => {
16061615 expect ( fallbackAfterRequest ?. email ) . toBe ( "beta-fresh@example.com" ) ;
16071616 } ) ;
16081617
1618+ it ( "preserves per-request fallback entitlement keys during concurrent fetch failover" , async ( ) => {
1619+ const { AccountManager } = await import ( "../lib/accounts.js" ) ;
1620+ const fetchHelpers = await import ( "../lib/request/fetch-helpers.js" ) ;
1621+ const entitlementModule = await import ( "../lib/entitlement-cache.js" ) ;
1622+ const accounts = [
1623+ {
1624+ index : 0 ,
1625+ accountId : "token-primary" ,
1626+ accountIdSource : "token" ,
1627+ email : "alpha@example.com" ,
1628+ refreshToken : "refresh-1" ,
1629+ accessToken : "access-alpha" ,
1630+ } ,
1631+ {
1632+ index : 1 ,
1633+ accountId : "workspace-fallback" ,
1634+ accountIdSource : "org" ,
1635+ email : "beta-stale@example.com" ,
1636+ refreshToken : "refresh-2" ,
1637+ accessToken : "access-beta" ,
1638+ idToken : "id-token-beta" ,
1639+ } ,
1640+ ] ;
1641+ let selectionCount = 0 ;
1642+ const manager = {
1643+ ...buildRoutingManager ( accounts ) ,
1644+ getCurrentOrNextForFamilyHybrid : ( ) =>
1645+ selectionCount ++ < 2 ? accounts [ 0 ] : accounts [ 1 ] ,
1646+ getCurrentOrNextForFamily : ( ) =>
1647+ selectionCount ++ < 2 ? accounts [ 0 ] : accounts [ 1 ] ,
1648+ } ;
1649+ vi . spyOn ( AccountManager , "loadFromDisk" ) . mockResolvedValueOnce ( manager as never ) ;
1650+ extractAccountIdMock
1651+ . mockReturnValueOnce ( "token-primary" )
1652+ . mockReturnValueOnce ( "token-primary" )
1653+ . mockReturnValueOnce ( "token-personal-a" )
1654+ . mockReturnValueOnce ( "token-personal-b" ) ;
1655+ extractAccountEmailMock
1656+ . mockReturnValueOnce ( "alpha@example.com" )
1657+ . mockReturnValueOnce ( "alpha@example.com" )
1658+ . mockReturnValueOnce ( "beta-fresh-a@example.com" )
1659+ . mockReturnValueOnce ( "beta-fresh-b@example.com" ) ;
1660+ vi . mocked ( fetchHelpers . createCodexHeaders ) . mockImplementation (
1661+ ( _init , accountId , accessToken ) =>
1662+ new Headers ( {
1663+ "x-test-account-id" : String ( accountId ) ,
1664+ "x-test-access-token" : String ( accessToken ) ,
1665+ } ) ,
1666+ ) ;
1667+ const blockedKeys : Array < [ string , string | undefined ] > = [ ] ;
1668+ const clearedKeys : Array < [ string , string | undefined ] > = [ ] ;
1669+ vi . spyOn ( entitlementModule . EntitlementCache . prototype , "isBlocked" ) . mockImplementation (
1670+ ( accountKey , modelKey ) => {
1671+ blockedKeys . push ( [ accountKey , modelKey ] ) ;
1672+ return { blocked : false , waitMs : 0 } ;
1673+ } ,
1674+ ) ;
1675+ vi . spyOn ( entitlementModule . EntitlementCache . prototype , "clear" ) . mockImplementation (
1676+ ( accountKey , modelKey ) => {
1677+ clearedKeys . push ( [ accountKey , modelKey ] ) ;
1678+ } ,
1679+ ) ;
1680+
1681+ let releasePrimaryFailures : ( ( ) => void ) | undefined ;
1682+ const primaryBarrier = new Promise < void > ( ( resolve ) => {
1683+ releasePrimaryFailures = resolve ;
1684+ } ) ;
1685+ let primaryAttempts = 0 ;
1686+ const fallbackResponses : Array < { accountId : string | null ; accessToken : string | null } > =
1687+ [ ] ;
1688+ globalThis . fetch = vi . fn ( ) . mockImplementation ( async ( _url , init ) => {
1689+ const headers = new Headers ( init ?. headers ) ;
1690+ const accountId = headers . get ( "x-test-account-id" ) ;
1691+ const accessToken = headers . get ( "x-test-access-token" ) ;
1692+ if ( accountId === "token-primary" ) {
1693+ primaryAttempts += 1 ;
1694+ if ( primaryAttempts === 2 ) {
1695+ releasePrimaryFailures ?.( ) ;
1696+ }
1697+ await primaryBarrier ;
1698+ throw new Error ( "Network timeout" ) ;
1699+ }
1700+
1701+ fallbackResponses . push ( { accountId, accessToken } ) ;
1702+ return new Response ( JSON . stringify ( { accountId, accessToken } ) , { status : 200 } ) ;
1703+ } ) ;
1704+
1705+ const { sdk } = await setupPlugin ( ) ;
1706+ const [ firstResponse , secondResponse ] = await Promise . all ( [
1707+ sdk . fetch ! ( "https://api.openai.com/v1/chat" , {
1708+ method : "POST" ,
1709+ body : JSON . stringify ( { model : "gpt-5.1" } ) ,
1710+ } ) ,
1711+ sdk . fetch ! ( "https://api.openai.com/v1/chat" , {
1712+ method : "POST" ,
1713+ body : JSON . stringify ( { model : "gpt-5.1" } ) ,
1714+ } ) ,
1715+ ] ) ;
1716+
1717+ expect ( firstResponse . status ) . toBe ( 200 ) ;
1718+ expect ( secondResponse . status ) . toBe ( 200 ) ;
1719+ const routedFetchCalls = vi
1720+ . mocked ( globalThis . fetch )
1721+ . mock . calls . map ( ( [ , init ] ) => {
1722+ const headers = new Headers ( init ?. headers ) ;
1723+ return headers . get ( "x-test-account-id" ) ;
1724+ } )
1725+ . filter ( ( accountId ) : accountId is string => accountId !== null ) ;
1726+ expect ( primaryAttempts ) . toBeGreaterThanOrEqual ( 2 ) ;
1727+ expect (
1728+ routedFetchCalls . filter ( ( accountId ) => accountId === "workspace-fallback" ) ,
1729+ ) . toHaveLength ( 2 ) ;
1730+ expect ( fallbackResponses ) . toEqual ( [
1731+ { accountId : "workspace-fallback" , accessToken : "access-beta" } ,
1732+ { accountId : "workspace-fallback" , accessToken : "access-beta" } ,
1733+ ] ) ;
1734+ expect ( blockedKeys ) . toEqual (
1735+ expect . arrayContaining ( [
1736+ [ "account:workspace-fallback::email:beta-fresh-a@example.com" , "gpt-5.1" ] ,
1737+ [ "account:workspace-fallback::email:beta-fresh-b@example.com" , "gpt-5.1" ] ,
1738+ ] ) ,
1739+ ) ;
1740+ expect ( clearedKeys ) . toEqual (
1741+ expect . arrayContaining ( [
1742+ [ "account:workspace-fallback::email:beta-fresh-a@example.com" , "gpt-5.1" ] ,
1743+ [ "account:workspace-fallback::email:beta-fresh-b@example.com" , "gpt-5.1" ] ,
1744+ ] ) ,
1745+ ) ;
1746+ const fallbackAfterRequests = manager . getAccountsSnapshot ( ) [ 1 ] ;
1747+ expect ( fallbackAfterRequests ?. accountId ) . toBe ( "workspace-fallback" ) ;
1748+ expect ( fallbackAfterRequests ?. accountIdSource ) . toBe ( "org" ) ;
1749+ expect ( syncCodexCliSelectionMock ) . toHaveBeenCalledWith ( 1 ) ;
1750+ } ) ;
1751+
16091752 it ( "handles network errors and rotates to next account" , async ( ) => {
16101753 globalThis . fetch = vi . fn ( ) . mockRejectedValue ( new Error ( "Network timeout" ) ) ;
16111754
0 commit comments