@@ -151,6 +151,9 @@ describe("AuthService", () => {
151151 string ,
152152 { name : string ; projects : { id : number ; name : string } [ ] }
153153 > ;
154+ // Overrides the /api/code/invites/check-access/ response (defaults to
155+ // granting access). May throw to simulate a network error.
156+ checkAccess ?: ( ) => Response ;
154157 } = { } ,
155158 ) => {
156159 const accountKey = options . accountKey ?? "user-1" ;
@@ -186,6 +189,9 @@ describe("AuthService", () => {
186189 } as unknown as Response ;
187190 }
188191
192+ if ( options . checkAccess ) {
193+ return options . checkAccess ( ) ;
194+ }
189195 return {
190196 ok : true ,
191197 json : vi . fn ( ) . mockResolvedValue ( { has_access : true } ) ,
@@ -1342,4 +1348,106 @@ describe("AuthService", () => {
13421348 expect ( redeemCallCount ) . toBe ( 2 ) ;
13431349 } ) ;
13441350 } ) ;
1351+
1352+ describe ( "code access resilience" , ( ) => {
1353+ const okBody = ( body : unknown ) : Response =>
1354+ ( {
1355+ ok : true ,
1356+ json : vi . fn ( ) . mockResolvedValue ( body ) ,
1357+ } ) as unknown as Response ;
1358+
1359+ beforeEach ( ( ) => {
1360+ seedStoredSession ( ) ;
1361+ oauthFlow . refreshToken . mockResolvedValue (
1362+ mockTokenResponse ( {
1363+ accessToken : "access-token" ,
1364+ refreshToken : "rotated-refresh-token" ,
1365+ } ) ,
1366+ ) ;
1367+ } ) ;
1368+
1369+ it . each ( [
1370+ {
1371+ name : "grants access when the server reports has_access true" ,
1372+ checkAccess : ( ) => okBody ( { has_access : true } ) ,
1373+ expected : true ,
1374+ } ,
1375+ {
1376+ name : "denies access when the server explicitly reports no access" ,
1377+ checkAccess : ( ) => okBody ( { has_access : false } ) ,
1378+ expected : false ,
1379+ } ,
1380+ {
1381+ name : "stays indeterminate when the check throws" ,
1382+ checkAccess : ( ) => {
1383+ throw new Error ( "network down" ) ;
1384+ } ,
1385+ expected : null ,
1386+ } ,
1387+ {
1388+ name : "stays indeterminate on a 2xx response without a has_access flag" ,
1389+ checkAccess : ( ) => okBody ( { } ) ,
1390+ expected : null ,
1391+ } ,
1392+ ] ) ( "$name" , async ( { checkAccess, expected } ) => {
1393+ stubAuthFetch ( { checkAccess } ) ;
1394+
1395+ await service . initialize ( ) ;
1396+
1397+ const state = service . getState ( ) ;
1398+ expect ( state . status ) . toBe ( "authenticated" ) ;
1399+ expect ( state . hasCodeAccess ) . toBe ( expected ) ;
1400+ } ) ;
1401+
1402+ it . each ( [
1403+ {
1404+ name : "a network error" ,
1405+ fail : ( ) : Response => {
1406+ throw new Error ( "network down" ) ;
1407+ } ,
1408+ } ,
1409+ {
1410+ name : "a 401" ,
1411+ fail : ( ) : Response =>
1412+ ( {
1413+ ok : false ,
1414+ status : 401 ,
1415+ json : vi . fn ( ) . mockResolvedValue ( { } ) ,
1416+ } ) as unknown as Response ,
1417+ } ,
1418+ ] ) (
1419+ "keeps a confirmed grant when a later check hits $name" ,
1420+ async ( { fail } ) => {
1421+ let shouldFail = false ;
1422+ stubAuthFetch ( {
1423+ checkAccess : ( ) =>
1424+ shouldFail ? fail ( ) : okBody ( { has_access : true } ) ,
1425+ } ) ;
1426+
1427+ await service . initialize ( ) ;
1428+ expect ( service . getState ( ) . hasCodeAccess ) . toBe ( true ) ;
1429+
1430+ shouldFail = true ;
1431+ await service . refreshAccessToken ( ) ;
1432+
1433+ expect ( service . getState ( ) . hasCodeAccess ) . toBe ( true ) ;
1434+ } ,
1435+ ) ;
1436+
1437+ it ( "recovers within the retry loop when a later attempt succeeds" , async ( ) => {
1438+ let attempts = 0 ;
1439+ stubAuthFetch ( {
1440+ checkAccess : ( ) => {
1441+ attempts += 1 ;
1442+ if ( attempts === 1 ) throw new Error ( "transient" ) ;
1443+ return okBody ( { has_access : true } ) ;
1444+ } ,
1445+ } ) ;
1446+
1447+ await service . initialize ( ) ;
1448+
1449+ expect ( service . getState ( ) . hasCodeAccess ) . toBe ( true ) ;
1450+ expect ( attempts ) . toBeGreaterThanOrEqual ( 2 ) ;
1451+ } ) ;
1452+ } ) ;
13451453} ) ;
0 commit comments