@@ -20,6 +20,7 @@ import { FetchHttpApi } from "../../../src/http-api/fetch";
2020import { TypedEventEmitter } from "../../../src/models/typed-event-emitter" ;
2121import {
2222 ClientPrefix ,
23+ ConnectionError ,
2324 HttpApiEvent ,
2425 type HttpApiEventHandlerMap ,
2526 IdentityPrefix ,
@@ -125,7 +126,7 @@ describe("FetchHttpApi", () => {
125126 ) . resolves . toBe ( text ) ;
126127 } ) ;
127128
128- it ( "should send token via query params if useAuthorizationHeader=false" , ( ) => {
129+ it ( "should send token via query params if useAuthorizationHeader=false" , async ( ) => {
129130 const fetchFn = jest . fn ( ) . mockResolvedValue ( { ok : true } ) ;
130131 const api = new FetchHttpApi ( new TypedEventEmitter < any , any > ( ) , {
131132 baseUrl,
@@ -134,19 +135,19 @@ describe("FetchHttpApi", () => {
134135 accessToken : "token" ,
135136 useAuthorizationHeader : false ,
136137 } ) ;
137- api . authedRequest ( Method . Get , "/path" ) ;
138+ await api . authedRequest ( Method . Get , "/path" ) ;
138139 expect ( fetchFn . mock . calls [ 0 ] [ 0 ] . searchParams . get ( "access_token" ) ) . toBe ( "token" ) ;
139140 } ) ;
140141
141- it ( "should send token via headers by default" , ( ) => {
142+ it ( "should send token via headers by default" , async ( ) => {
142143 const fetchFn = jest . fn ( ) . mockResolvedValue ( { ok : true } ) ;
143144 const api = new FetchHttpApi ( new TypedEventEmitter < any , any > ( ) , {
144145 baseUrl,
145146 prefix,
146147 fetchFn,
147148 accessToken : "token" ,
148149 } ) ;
149- api . authedRequest ( Method . Get , "/path" ) ;
150+ await api . authedRequest ( Method . Get , "/path" ) ;
150151 expect ( fetchFn . mock . calls [ 0 ] [ 1 ] . headers [ "Authorization" ] ) . toBe ( "Bearer token" ) ;
151152 } ) ;
152153
@@ -163,7 +164,7 @@ describe("FetchHttpApi", () => {
163164 expect ( fetchFn . mock . calls [ 0 ] [ 1 ] . headers [ "Authorization" ] ) . toBeFalsy ( ) ;
164165 } ) ;
165166
166- it ( "should ensure no token is leaked out via query params if sending via headers" , ( ) => {
167+ it ( "should ensure no token is leaked out via query params if sending via headers" , async ( ) => {
167168 const fetchFn = jest . fn ( ) . mockResolvedValue ( { ok : true } ) ;
168169 const api = new FetchHttpApi ( new TypedEventEmitter < any , any > ( ) , {
169170 baseUrl,
@@ -172,12 +173,12 @@ describe("FetchHttpApi", () => {
172173 accessToken : "token" ,
173174 useAuthorizationHeader : true ,
174175 } ) ;
175- api . authedRequest ( Method . Get , "/path" , { access_token : "123" } ) ;
176+ await api . authedRequest ( Method . Get , "/path" , { access_token : "123" } ) ;
176177 expect ( fetchFn . mock . calls [ 0 ] [ 0 ] . searchParams . get ( "access_token" ) ) . toBeFalsy ( ) ;
177178 expect ( fetchFn . mock . calls [ 0 ] [ 1 ] . headers [ "Authorization" ] ) . toBe ( "Bearer token" ) ;
178179 } ) ;
179180
180- it ( "should not override manually specified access token via query params" , ( ) => {
181+ it ( "should not override manually specified access token via query params" , async ( ) => {
181182 const fetchFn = jest . fn ( ) . mockResolvedValue ( { ok : true } ) ;
182183 const api = new FetchHttpApi ( new TypedEventEmitter < any , any > ( ) , {
183184 baseUrl,
@@ -186,11 +187,11 @@ describe("FetchHttpApi", () => {
186187 accessToken : "token" ,
187188 useAuthorizationHeader : false ,
188189 } ) ;
189- api . authedRequest ( Method . Get , "/path" , { access_token : "RealToken" } ) ;
190+ await api . authedRequest ( Method . Get , "/path" , { access_token : "RealToken" } ) ;
190191 expect ( fetchFn . mock . calls [ 0 ] [ 0 ] . searchParams . get ( "access_token" ) ) . toBe ( "RealToken" ) ;
191192 } ) ;
192193
193- it ( "should not override manually specified access token via header" , ( ) => {
194+ it ( "should not override manually specified access token via header" , async ( ) => {
194195 const fetchFn = jest . fn ( ) . mockResolvedValue ( { ok : true } ) ;
195196 const api = new FetchHttpApi ( new TypedEventEmitter < any , any > ( ) , {
196197 baseUrl,
@@ -199,16 +200,16 @@ describe("FetchHttpApi", () => {
199200 accessToken : "token" ,
200201 useAuthorizationHeader : true ,
201202 } ) ;
202- api . authedRequest ( Method . Get , "/path" , undefined , undefined , {
203+ await api . authedRequest ( Method . Get , "/path" , undefined , undefined , {
203204 headers : { Authorization : "Bearer RealToken" } ,
204205 } ) ;
205206 expect ( fetchFn . mock . calls [ 0 ] [ 1 ] . headers [ "Authorization" ] ) . toBe ( "Bearer RealToken" ) ;
206207 } ) ;
207208
208- it ( "should not override Accept header" , ( ) => {
209+ it ( "should not override Accept header" , async ( ) => {
209210 const fetchFn = jest . fn ( ) . mockResolvedValue ( { ok : true } ) ;
210211 const api = new FetchHttpApi ( new TypedEventEmitter < any , any > ( ) , { baseUrl, prefix, fetchFn } ) ;
211- api . authedRequest ( Method . Get , "/path" , undefined , undefined , {
212+ await api . authedRequest ( Method . Get , "/path" , undefined , undefined , {
212213 headers : { Accept : "text/html" } ,
213214 } ) ;
214215 expect ( fetchFn . mock . calls [ 0 ] [ 1 ] . headers [ "Accept" ] ) . toBe ( "text/html" ) ;
@@ -288,7 +289,7 @@ describe("FetchHttpApi", () => {
288289
289290 describe ( "with a tokenRefreshFunction" , ( ) => {
290291 it ( "should emit logout and throw when token refresh fails" , async ( ) => {
291- const error = new Error ( "uh oh" ) ;
292+ const error = new MatrixError ( ) ;
292293 const tokenRefreshFunction = jest . fn ( ) . mockRejectedValue ( error ) ;
293294 const fetchFn = jest . fn ( ) . mockResolvedValue ( unknownTokenResponse ) ;
294295 const emitter = new TypedEventEmitter < HttpApiEvent , HttpApiEventHandlerMap > ( ) ;
@@ -308,6 +309,27 @@ describe("FetchHttpApi", () => {
308309 expect ( emitter . emit ) . toHaveBeenCalledWith ( HttpApiEvent . SessionLoggedOut , unknownTokenErr ) ;
309310 } ) ;
310311
312+ it ( "should not emit logout but still throw when token refresh fails due to transitive fault" , async ( ) => {
313+ const error = new ConnectionError ( "transitive fault" ) ;
314+ const tokenRefreshFunction = jest . fn ( ) . mockRejectedValue ( error ) ;
315+ const fetchFn = jest . fn ( ) . mockResolvedValue ( unknownTokenResponse ) ;
316+ const emitter = new TypedEventEmitter < HttpApiEvent , HttpApiEventHandlerMap > ( ) ;
317+ jest . spyOn ( emitter , "emit" ) ;
318+ const api = new FetchHttpApi ( emitter , {
319+ baseUrl,
320+ prefix,
321+ fetchFn,
322+ tokenRefreshFunction,
323+ accessToken,
324+ refreshToken,
325+ } ) ;
326+ await expect ( api . authedRequest ( Method . Post , "/account/password" ) ) . rejects . toThrow (
327+ unknownTokenErr ,
328+ ) ;
329+ expect ( tokenRefreshFunction ) . toHaveBeenCalledWith ( refreshToken ) ;
330+ expect ( emitter . emit ) . not . toHaveBeenCalledWith ( HttpApiEvent . SessionLoggedOut , unknownTokenErr ) ;
331+ } ) ;
332+
311333 it ( "should refresh token and retry request" , async ( ) => {
312334 const newAccessToken = "new-access-token" ;
313335 const newRefreshToken = "new-refresh-token" ;
@@ -468,4 +490,61 @@ describe("FetchHttpApi", () => {
468490 ]
469491 ` ) ;
470492 } ) ;
493+
494+ it ( "should not make multiple concurrent refresh token requests" , async ( ) => {
495+ const tokenInactiveError = new MatrixError ( { errcode : "M_UNKNOWN_TOKEN" , error : "Token is not active" } , 401 ) ;
496+
497+ const deferredTokenRefresh = defer < { accessToken : string ; refreshToken : string } > ( ) ;
498+ const fetchFn = jest . fn ( ) . mockResolvedValue ( {
499+ ok : false ,
500+ status : tokenInactiveError . httpStatus ,
501+ async text ( ) {
502+ return JSON . stringify ( tokenInactiveError . data ) ;
503+ } ,
504+ async json ( ) {
505+ return tokenInactiveError . data ;
506+ } ,
507+ headers : {
508+ get : jest . fn ( ) . mockReturnValue ( "application/json" ) ,
509+ } ,
510+ } ) ;
511+ const tokenRefreshFunction = jest . fn ( ) . mockReturnValue ( deferredTokenRefresh . promise ) ;
512+
513+ const api = new FetchHttpApi ( new TypedEventEmitter < any , any > ( ) , {
514+ baseUrl,
515+ prefix,
516+ fetchFn,
517+ doNotAttemptTokenRefresh : false ,
518+ tokenRefreshFunction,
519+ accessToken : "ACCESS_TOKEN" ,
520+ refreshToken : "REFRESH_TOKEN" ,
521+ } ) ;
522+
523+ const prom1 = api . authedRequest ( Method . Get , "/path1" ) ;
524+ const prom2 = api . authedRequest ( Method . Get , "/path2" ) ;
525+
526+ await jest . advanceTimersByTimeAsync ( 10 ) ; // wait for requests to fire
527+ expect ( fetchFn ) . toHaveBeenCalledTimes ( 2 ) ;
528+ fetchFn . mockResolvedValue ( {
529+ ok : true ,
530+ status : 200 ,
531+ async text ( ) {
532+ return "{}" ;
533+ } ,
534+ async json ( ) {
535+ return { } ;
536+ } ,
537+ headers : {
538+ get : jest . fn ( ) . mockReturnValue ( "application/json" ) ,
539+ } ,
540+ } ) ;
541+ deferredTokenRefresh . resolve ( { accessToken : "NEW_ACCESS_TOKEN" , refreshToken : "NEW_REFRESH_TOKEN" } ) ;
542+
543+ await prom1 ;
544+ await prom2 ;
545+ expect ( fetchFn ) . toHaveBeenCalledTimes ( 4 ) ; // 2 original calls + 2 retries
546+ expect ( tokenRefreshFunction ) . toHaveBeenCalledTimes ( 1 ) ;
547+ expect ( api . opts . accessToken ) . toBe ( "NEW_ACCESS_TOKEN" ) ;
548+ expect ( api . opts . refreshToken ) . toBe ( "NEW_REFRESH_TOKEN" ) ;
549+ } ) ;
471550} ) ;
0 commit comments