11import { configureStore } from '@reduxjs/toolkit' ;
2+ import type { BaseQueryApi } from '@reduxjs/toolkit/query' ;
3+ import { tokenRefreshed } from 'features/auth/store/authSlice' ;
4+ import { markTokenRefreshAccepted } from 'features/auth/store/authTokenRefresh' ;
25import { authApi } from 'services/api/endpoints/auth' ;
3- import { beforeAll , beforeEach , describe , expect , it , vi } from 'vitest' ;
6+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
47
5- import { api } from '..' ;
8+ import { api , buildV1Url , dynamicBaseQuery } from '..' ;
69
710/**
811 * `dynamicBaseQuery` reads the bearer token out of localStorage, and `getDeploymentBaseUrl`
912 * reads `window.location.origin`. Neither exists in the default (node) test environment.
1013 */
11- beforeAll ( ( ) => {
12- const values = new Map < string , string > ( ) ;
14+ const values = new Map < string , string > ( ) ;
15+
16+ beforeEach ( ( ) => {
17+ values . clear ( ) ;
1318 vi . stubGlobal ( 'localStorage' , {
1419 clear : ( ) => values . clear ( ) ,
1520 getItem : ( key : string ) => values . get ( key ) ?? null ,
@@ -23,8 +28,9 @@ beforeAll(() => {
2328 vi . stubGlobal ( 'window' , { location : { origin : 'http://localhost' } } ) ;
2429} ) ;
2530
26- beforeEach ( ( ) => {
27- localStorage . clear ( ) ;
31+ afterEach ( ( ) => {
32+ vi . restoreAllMocks ( ) ;
33+ vi . unstubAllGlobals ( ) ;
2834} ) ;
2935
3036const buildStore = ( ) =>
@@ -33,6 +39,138 @@ const buildStore = () =>
3339 middleware : ( getDefaultMiddleware ) => getDefaultMiddleware ( ) . concat ( api . middleware ) ,
3440 } ) ;
3541
42+ const tokenFor = ( nonce : number , epoch ?: number ) =>
43+ `header.${ btoa (
44+ JSON . stringify ( { user_id : 'user-1' , nonce, ...( epoch === undefined ? { } : { token_epoch : epoch } ) } )
45+ ) } .signature`;
46+
47+ describe ( 'refreshed token acceptance' , ( ) => {
48+ it . each ( [
49+ [ 'an explicit epoch-zero token' , tokenFor ( 1 , 0 ) ] ,
50+ [ 'a legacy token without an epoch claim' , tokenFor ( 1 ) ] ,
51+ ] ) (
52+ 'accepts an epoch-changing replacement for %s inside the routine refresh throttle window' ,
53+ async ( _ , requestToken ) => {
54+ const refreshedToken = tokenFor ( 2 , 1 ) ;
55+ localStorage . setItem ( 'auth_token' , requestToken ) ;
56+ markTokenRefreshAccepted ( ) ;
57+
58+ const events : string [ ] = [ ] ;
59+ const dispatch = vi . fn ( ( ) => events . push ( 'dispatch' ) ) ;
60+ const fetchMock = vi . fn ( ( input : string | URL | Request , init ?: RequestInit ) => {
61+ const url = input instanceof Request ? input . url : input . toString ( ) ;
62+ if ( url . endsWith ( '/api/v1/auth/media-cookie' ) ) {
63+ events . push ( 'media-cookie' ) ;
64+ expect ( new Headers ( init ?. headers ) . get ( 'Authorization' ) ) . toBe ( `Bearer ${ refreshedToken } ` ) ;
65+ return Promise . resolve ( new Response ( null , { status : 204 } ) ) ;
66+ }
67+ return Promise . resolve (
68+ new Response ( '{}' , {
69+ headers : { 'content-type' : 'application/json' , 'X-Refreshed-Token' : refreshedToken } ,
70+ } )
71+ ) ;
72+ } ) ;
73+ vi . stubGlobal ( 'fetch' , fetchMock ) ;
74+
75+ await dynamicBaseQuery (
76+ buildV1Url ( 'images/i/example.png' ) ,
77+ {
78+ dispatch,
79+ getState : ( ) => ( { } ) ,
80+ signal : new AbortController ( ) . signal ,
81+ abort : ( ) => { } ,
82+ endpoint : 'getImageDTO' ,
83+ type : 'query' ,
84+ forced : false ,
85+ extra : undefined ,
86+ } as unknown as BaseQueryApi ,
87+ { }
88+ ) ;
89+
90+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
91+ expect ( dispatch ) . toHaveBeenCalledWith ( tokenRefreshed ( refreshedToken ) ) ;
92+ expect ( events ) . toEqual ( [ 'media-cookie' , 'dispatch' ] ) ;
93+ }
94+ ) ;
95+
96+ it ( 'keeps a same-epoch replacement inside the routine refresh throttle window' , async ( ) => {
97+ const requestToken = tokenFor ( 1 , 1 ) ;
98+ const refreshedToken = tokenFor ( 2 , 1 ) ;
99+ localStorage . setItem ( 'auth_token' , requestToken ) ;
100+ markTokenRefreshAccepted ( ) ;
101+
102+ const dispatch = vi . fn ( ) ;
103+ const fetchMock = vi . fn ( ( ) =>
104+ Promise . resolve (
105+ new Response ( '{}' , {
106+ headers : { 'content-type' : 'application/json' , 'X-Refreshed-Token' : refreshedToken } ,
107+ } )
108+ )
109+ ) ;
110+ vi . stubGlobal ( 'fetch' , fetchMock ) ;
111+
112+ await dynamicBaseQuery (
113+ buildV1Url ( 'images/i/example.png' ) ,
114+ {
115+ dispatch,
116+ getState : ( ) => ( { } ) ,
117+ signal : new AbortController ( ) . signal ,
118+ abort : ( ) => { } ,
119+ endpoint : 'getImageDTO' ,
120+ type : 'query' ,
121+ forced : false ,
122+ extra : undefined ,
123+ } as unknown as BaseQueryApi ,
124+ { }
125+ ) ;
126+
127+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
128+ expect ( dispatch ) . not . toHaveBeenCalled ( ) ;
129+ } ) ;
130+
131+ it ( 'commits a same-epoch replacement after the routine refresh throttle window' , async ( ) => {
132+ const now = vi . spyOn ( Date , 'now' ) . mockReturnValue ( 300_000 ) ;
133+ const requestToken = tokenFor ( 1 , 1 ) ;
134+ const refreshedToken = tokenFor ( 2 , 1 ) ;
135+ localStorage . setItem ( 'auth_token' , requestToken ) ;
136+ markTokenRefreshAccepted ( ) ;
137+ now . mockReturnValue ( 360_001 ) ;
138+
139+ const dispatch = vi . fn ( ) ;
140+ const fetchMock = vi . fn ( ( input : string | URL | Request , init ?: RequestInit ) => {
141+ const url = input instanceof Request ? input . url : input . toString ( ) ;
142+ if ( url . endsWith ( '/api/v1/auth/media-cookie' ) ) {
143+ expect ( new Headers ( init ?. headers ) . get ( 'Authorization' ) ) . toBe ( `Bearer ${ refreshedToken } ` ) ;
144+ return Promise . resolve ( new Response ( null , { status : 204 } ) ) ;
145+ }
146+ return Promise . resolve (
147+ new Response ( '{}' , {
148+ headers : { 'content-type' : 'application/json' , 'X-Refreshed-Token' : refreshedToken } ,
149+ } )
150+ ) ;
151+ } ) ;
152+ vi . stubGlobal ( 'fetch' , fetchMock ) ;
153+
154+ await dynamicBaseQuery (
155+ buildV1Url ( 'images/i/example.png' ) ,
156+ {
157+ dispatch,
158+ getState : ( ) => ( { } ) ,
159+ signal : new AbortController ( ) . signal ,
160+ abort : ( ) => { } ,
161+ endpoint : 'getImageDTO' ,
162+ type : 'query' ,
163+ forced : false ,
164+ extra : undefined ,
165+ } as unknown as BaseQueryApi ,
166+ { }
167+ ) ;
168+
169+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
170+ expect ( dispatch ) . toHaveBeenCalledWith ( tokenRefreshed ( refreshedToken ) ) ;
171+ } ) ;
172+ } ) ;
173+
36174describe ( 'getCurrentUser' , ( ) => {
37175 it ( 'does not let a replacement session read the 401 of the token it replaced' , async ( ) => {
38176 // The sequence this exists for: a tab page-loads with an expired token and asks who it is;
0 commit comments