@@ -518,13 +518,12 @@ describe("oauth refresh hardening", () => {
518518 } ) ;
519519
520520 /**
521- * A non-terminal refresh failure is reported as retryable, but the refresh intent outlived
522- * it. The next attempt then hit the pending-intent branch and raised OAuthLoginRequiredError,
523- * so a single 503 or timeout locked the account out of refresh until manual re-authentication
524- * even after upstream recovered. The replay guard is unaffected: an intent whose outcome is
525- * genuinely unknown is reported `uncertain` by the store and is still never cleared here.
521+ * A definitive non-terminal HTTP failure is retryable: the endpoint answered with a failure,
522+ * so the durable intent must not turn that promised retry into OAuthLoginRequiredError.
523+ * A timeout or unreadable response is different — the provider may already have rotated the
524+ * token, so that post-dispatch intent remains as the replay guard.
526525 */
527- test ( "a transient Anthropic failure leaves the account refreshable" , async ( ) => {
526+ test ( "a definitive transient Anthropic HTTP failure leaves the account refreshable" , async ( ) => {
528527 await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
529528 const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
530529 const transient = new AnthropicTokenError ( "server" , 503 , undefined ) ;
@@ -542,6 +541,120 @@ describe("oauth refresh hardening", () => {
542541 expect ( getAccountSet ( "anthropic" ) ! . accounts . find ( account => account . id === id ) ! . needsReauth ) . toBeUndefined ( ) ;
543542 } ) ;
544543
544+ test ( "an Anthropic refresh with an uncertain post-dispatch outcome preserves its intent" , async ( ) => {
545+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
546+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
547+ const credential = getAccountCredential ( "anthropic" , id ) ! ;
548+ const generation = credentialGeneration ( credential ) ;
549+ const timeout = new AnthropicTokenError ( "timeout" , undefined , undefined ) ;
550+
551+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , {
552+ ...OAUTH_PROVIDERS . anthropic ! ,
553+ refresh : async ( ) => { throw timeout ; } ,
554+ } , credential ) ) . rejects . toBe ( timeout ) ;
555+
556+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ) . toMatchObject ( { generation } ) ;
557+ expect ( getAccountSet ( "anthropic" ) ! . accounts . find ( account => account . id === id ) ! . needsReauth ) . toBeUndefined ( ) ;
558+ } ) ;
559+
560+ test ( "a pre-dispatch Anthropic abort clears its unconsumed refresh intent" , async ( ) => {
561+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
562+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
563+ const credential = getAccountCredential ( "anthropic" , id ) ! ;
564+ const aborted = new Error ( "aborted before dispatch" ) ;
565+ const controller = new AbortController ( ) ;
566+ controller . abort ( aborted ) ;
567+ let calls = 0 ;
568+
569+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , {
570+ ...OAUTH_PROVIDERS . anthropic ! ,
571+ refresh : async ( ) => { calls += 1 ; throw new Error ( "must not run" ) ; } ,
572+ } , credential , { signal : controller . signal } ) ) . rejects . toBe ( aborted ) ;
573+
574+ expect ( calls ) . toBe ( 0 ) ;
575+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ) . toBeUndefined ( ) ;
576+ } ) ;
577+
578+ test ( "intent cleanup failure preserves the original Anthropic HTTP error" , async ( ) => {
579+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
580+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
581+ const credential = getAccountCredential ( "anthropic" , id ) ! ;
582+ const generation = credentialGeneration ( credential ) ;
583+ const transient = new AnthropicTokenError ( "server" , 503 , undefined ) ;
584+ const clearFailure = new Error ( "intent unlink failed" ) ;
585+ const clearSpy = spyOn ( storeModule , "clearOAuthRefreshIntent" ) . mockImplementation ( ( ) => {
586+ throw clearFailure ;
587+ } ) ;
588+ const warnSpy = spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } ) ;
589+ try {
590+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , {
591+ ...OAUTH_PROVIDERS . anthropic ! ,
592+ refresh : async ( ) => { throw transient ; } ,
593+ } , credential ) ) . rejects . toBe ( transient ) ;
594+
595+ expect ( clearSpy ) . toHaveBeenCalled ( ) ;
596+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ) . toMatchObject ( { generation } ) ;
597+ expect ( getAccountSet ( "anthropic" ) ! . accounts . find ( account => account . id === id ) ! . needsReauth ) . toBeUndefined ( ) ;
598+ } finally {
599+ warnSpy . mockRestore ( ) ;
600+ clearSpy . mockRestore ( ) ;
601+ }
602+ } ) ;
603+
604+ test ( "Anthropic persistence failure after provider success preserves the replay guard" , async ( ) => {
605+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
606+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
607+ const credential = getAccountCredential ( "anthropic" , id ) ! ;
608+ const generation = credentialGeneration ( credential ) ;
609+ const persistenceFailure = new Error ( "credential persistence failed" ) ;
610+ const mergeSpy = spyOn ( storeModule , "mergeAccountCredential" ) . mockImplementation ( async ( ) => {
611+ throw persistenceFailure ;
612+ } ) ;
613+ try {
614+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , {
615+ ...OAUTH_PROVIDERS . anthropic ! ,
616+ refresh : async ( ) => ( {
617+ access : "fresh" ,
618+ refresh : "rt-fresh" ,
619+ expires : Date . now ( ) + 3_600_000 ,
620+ } ) ,
621+ } , credential ) ) . rejects . toBe ( persistenceFailure ) ;
622+
623+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ) . toMatchObject ( { generation } ) ;
624+ expect ( getAccountCredential ( "anthropic" , id ) ) . toEqual ( credential ) ;
625+ expect ( getAccountSet ( "anthropic" ) ! . accounts . find ( account => account . id === id ) ! . needsReauth ) . toBeUndefined ( ) ;
626+ } finally {
627+ mergeSpy . mockRestore ( ) ;
628+ }
629+ } ) ;
630+
631+ test ( "post-persist intent cleanup failure does not turn Anthropic refresh success into failure" , async ( ) => {
632+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
633+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
634+ const credential = getAccountCredential ( "anthropic" , id ) ! ;
635+ const oldGeneration = credentialGeneration ( credential ) ;
636+ const clearSpy = spyOn ( storeModule , "clearOAuthRefreshIntent" ) . mockImplementation ( ( ) => {
637+ throw new Error ( "intent unlink failed" ) ;
638+ } ) ;
639+ const warnSpy = spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } ) ;
640+ try {
641+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , {
642+ ...OAUTH_PROVIDERS . anthropic ! ,
643+ refresh : async ( ) => ( {
644+ access : "fresh" ,
645+ refresh : "rt-fresh" ,
646+ expires : Date . now ( ) + 3_600_000 ,
647+ } ) ,
648+ } , credential ) ) . resolves . toBe ( "fresh" ) ;
649+
650+ expect ( getAccountCredential ( "anthropic" , id ) ?. access ) . toBe ( "fresh" ) ;
651+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ) . toMatchObject ( { generation : oldGeneration } ) ;
652+ } finally {
653+ warnSpy . mockRestore ( ) ;
654+ clearSpy . mockRestore ( ) ;
655+ }
656+ } ) ;
657+
545658 test ( "Anthropic post-dispatch stale flight replacement stays retryable without replay or reauth" , async ( ) => {
546659 await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
547660 const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
0 commit comments