@@ -11,6 +11,7 @@ import {
1111 getAccountIdCandidates ,
1212} from "../lib/accounts.js" ;
1313import { getHealthTracker , getTokenTracker , resetTrackers } from "../lib/rotation.js" ;
14+ import { CodexAuthError } from "../lib/errors.js" ;
1415import type { OAuthAuthDetails } from "../lib/types.js" ;
1516
1617vi . mock ( "../lib/storage.js" , async ( importOriginal ) => {
@@ -22,6 +23,29 @@ vi.mock("../lib/storage.js", async (importOriginal) => {
2223 } ;
2324} ) ;
2425
26+ beforeEach ( async ( ) => {
27+ resetTrackers ( ) ;
28+ const { saveAccounts, withAccountStorageTransaction } = await import (
29+ "../lib/storage.js"
30+ ) ;
31+ const mockSaveAccounts = vi . mocked ( saveAccounts ) ;
32+ const mockWithAccountStorageTransaction = vi . mocked (
33+ withAccountStorageTransaction ,
34+ ) ;
35+
36+ mockSaveAccounts . mockReset ( ) ;
37+ mockSaveAccounts . mockResolvedValue ( undefined ) ;
38+ mockWithAccountStorageTransaction . mockReset ( ) ;
39+ mockWithAccountStorageTransaction . mockImplementation ( async ( handler ) => {
40+ let current = null ;
41+ const persist = async ( storage : Parameters < typeof saveAccounts > [ 0 ] ) => {
42+ current = structuredClone ( storage ) ;
43+ await mockSaveAccounts ( storage ) ;
44+ } ;
45+ return handler ( current as never , persist ) ;
46+ } ) ;
47+ } ) ;
48+
2549describe ( "parseRateLimitReason" , ( ) => {
2650 it ( "returns quota for quota-related codes" , ( ) => {
2751 expect ( parseRateLimitReason ( "usage_limit_reached" ) ) . toBe ( "quota" ) ;
@@ -1134,6 +1158,144 @@ describe("AccountManager", () => {
11341158 expect ( account . cooldownReason ) . toBeUndefined ( ) ;
11351159 expect ( account . consecutiveAuthFailures ) . toBe ( 0 ) ;
11361160 } ) ;
1161+
1162+ it ( "propagates storage write failure as retryable CodexAuthError" , async ( ) => {
1163+ const { withAccountStorageTransaction } = await import ( "../lib/storage.js" ) ;
1164+ const mockWithAccountStorageTransaction = vi . mocked (
1165+ withAccountStorageTransaction ,
1166+ ) ;
1167+ mockWithAccountStorageTransaction . mockRejectedValueOnce (
1168+ Object . assign ( new Error ( "EBUSY" ) , { code : "EBUSY" } ) ,
1169+ ) ;
1170+
1171+ const now = Date . now ( ) ;
1172+ const stored = {
1173+ version : 3 as const ,
1174+ activeIndex : 0 ,
1175+ accounts : [
1176+ {
1177+ refreshToken : "old-refresh" ,
1178+ accessToken : "old-access" ,
1179+ expiresAt : now ,
1180+ addedAt : now ,
1181+ lastUsed : now ,
1182+ } ,
1183+ ] ,
1184+ } ;
1185+ const manager = new AccountManager ( undefined , stored as any ) ;
1186+ const account = manager . getAccountByIndex ( 0 ) ! ;
1187+ const refreshedAuth : OAuthAuthDetails = {
1188+ type : "oauth" ,
1189+ access : "header.payload.signature" ,
1190+ refresh : "new-refresh" ,
1191+ expires : now + 3_600_000 ,
1192+ } ;
1193+
1194+ const error = await manager . commitRefreshedAuth ( account , refreshedAuth ) . catch (
1195+ ( err ) => err as CodexAuthError ,
1196+ ) ;
1197+
1198+ expect ( error ) . toBeInstanceOf ( CodexAuthError ) ;
1199+ expect ( error . retryable ) . toBe ( true ) ;
1200+ expect ( account . refreshToken ) . toBe ( "old-refresh" ) ;
1201+ } ) ;
1202+
1203+ it ( "prevents debounced saves from writing stale auth during refresh persistence" , async ( ) => {
1204+ vi . useFakeTimers ( ) ;
1205+ try {
1206+ const { saveAccounts, withAccountStorageTransaction } = await import (
1207+ "../lib/storage.js"
1208+ ) ;
1209+ const mockSaveAccounts = vi . mocked ( saveAccounts ) ;
1210+ const mockWithAccountStorageTransaction = vi . mocked (
1211+ withAccountStorageTransaction ,
1212+ ) ;
1213+
1214+ const now = Date . now ( ) ;
1215+ const stored = {
1216+ version : 3 as const ,
1217+ activeIndex : 0 ,
1218+ accounts : [
1219+ {
1220+ refreshToken : "old-refresh" ,
1221+ accessToken : "old-access" ,
1222+ expiresAt : now ,
1223+ addedAt : now ,
1224+ lastUsed : now ,
1225+ } ,
1226+ ] ,
1227+ } ;
1228+ const manager = new AccountManager ( undefined , stored as any ) ;
1229+ const account = manager . getAccountByIndex ( 0 ) ! ;
1230+ const refreshedAuth : OAuthAuthDetails = {
1231+ type : "oauth" ,
1232+ access : "new-access" ,
1233+ refresh : "new-refresh" ,
1234+ expires : now + 3_600_000 ,
1235+ } ;
1236+
1237+ let storageState = structuredClone ( stored ) as typeof stored ;
1238+ let lock = Promise . resolve ( ) ;
1239+ let releasePersist ! : ( ) => void ;
1240+ const persistBlocked = new Promise < void > ( ( resolve ) => {
1241+ releasePersist = resolve ;
1242+ } ) ;
1243+ let notifyPersistStarted ! : ( ) => void ;
1244+ const persistStarted = new Promise < void > ( ( resolve ) => {
1245+ notifyPersistStarted = resolve ;
1246+ } ) ;
1247+
1248+ mockWithAccountStorageTransaction . mockImplementation ( ( handler ) => {
1249+ const run = async ( ) => {
1250+ const current = structuredClone ( storageState ) as typeof storageState ;
1251+ const persist = async (
1252+ nextStorage : Parameters < typeof saveAccounts > [ 0 ] ,
1253+ ) => {
1254+ if (
1255+ nextStorage . accounts [ 0 ] ?. refreshToken === "new-refresh" &&
1256+ nextStorage . accounts [ 0 ] ?. accessToken === "new-access"
1257+ ) {
1258+ notifyPersistStarted ( ) ;
1259+ await persistBlocked ;
1260+ }
1261+ storageState = structuredClone ( nextStorage ) as typeof storageState ;
1262+ await mockSaveAccounts ( nextStorage ) ;
1263+ } ;
1264+ return handler ( current as never , persist ) ;
1265+ } ;
1266+
1267+ const result = lock . then ( run , run ) ;
1268+ lock = result . then (
1269+ ( ) => undefined ,
1270+ ( ) => undefined ,
1271+ ) ;
1272+ return result ;
1273+ } ) ;
1274+
1275+ const commitPromise = manager . commitRefreshedAuth ( account , refreshedAuth ) ;
1276+ await persistStarted ;
1277+
1278+ manager . saveToDiskDebounced ( 0 ) ;
1279+ await vi . advanceTimersByTimeAsync ( 0 ) ;
1280+
1281+ releasePersist ( ) ;
1282+ await commitPromise ;
1283+ await manager . flushPendingSave ( ) ;
1284+
1285+ expect ( mockSaveAccounts ) . toHaveBeenCalledTimes ( 2 ) ;
1286+ expect ( mockSaveAccounts . mock . calls [ 0 ] ?. [ 0 ] ?. accounts [ 0 ] ?. refreshToken ) . toBe (
1287+ "new-refresh" ,
1288+ ) ;
1289+ expect ( mockSaveAccounts . mock . calls [ 1 ] ?. [ 0 ] ?. accounts [ 0 ] ?. refreshToken ) . toBe (
1290+ "new-refresh" ,
1291+ ) ;
1292+ expect ( mockSaveAccounts . mock . calls [ 1 ] ?. [ 0 ] ?. accounts [ 0 ] ?. accessToken ) . toBe (
1293+ "new-access" ,
1294+ ) ;
1295+ } finally {
1296+ vi . useRealTimers ( ) ;
1297+ }
1298+ } ) ;
11371299 } ) ;
11381300
11391301 describe ( "toAuthDetails" , ( ) => {
0 commit comments