11import { http , HttpResponse } from "msw"
22import { server } from "./mocks/server"
33import { redirectTo } from "./navigation"
4- import { exchangeCodeForToken , logout } from "./auth"
4+ import { captureBugsnagReports } from "./test/bugsnag"
5+ import { currentAccessToken , exchangeCodeForToken , logout , refreshAccessToken , SessionExpiredError } from "./auth"
6+ import grantedToken from "./test/fixtures/spotify/documented/token.json"
7+ import refreshedToken from "./test/fixtures/spotify/documented/token-refresh.json"
8+ import revokedRefreshToken from "./test/fixtures/spotify/documented/token-refresh-revoked.json"
9+
10+ const tokenUrl = "https://accounts.spotify.com/api/token"
11+ const HOUR = 60 * 60 * 1000
12+
13+ // Records each token request body and answers with respond
14+ function mockTokenEndpoint ( respond : ( ) => Response | Promise < Response > ) {
15+ const requests : URLSearchParams [ ] = [ ]
16+ server . use ( http . post ( tokenUrl , async ( { request } ) => {
17+ requests . push ( new URLSearchParams ( await request . text ( ) ) )
18+ return respond ( )
19+ } ) )
20+ return requests
21+ }
22+
23+ const refresh = ( body : object = refreshedToken . body ) => ( ) => HttpResponse . json ( body )
24+ const revoke = ( ) => HttpResponse . json ( revokedRefreshToken . body , { status : revokedRefreshToken . status } )
25+
26+ function logIn ( { expiresAt = Date . now ( ) + HOUR , clientId } : { expiresAt ?: number , clientId ?: string } = { } ) {
27+ localStorage . setItem ( "access_token" , "TEST_ACCESS_TOKEN" )
28+ localStorage . setItem ( "refresh_token" , "TEST_REFRESH_TOKEN" )
29+ localStorage . setItem ( "token_expires_at" , String ( expiresAt ) )
30+ if ( clientId ) { localStorage . setItem ( "client_id" , clientId ) }
31+ }
532
633beforeEach ( ( ) => {
34+ vi . useFakeTimers ( { toFake : [ "Date" ] } )
735 window . history . replaceState ( null , "" , "/exportify" )
836} )
937
38+ afterEach ( ( ) => {
39+ vi . useRealTimers ( )
40+ } )
41+
1042describe ( "exchangeCodeForToken" , ( ) => {
11- test ( "stores the token and discards the single-use code verifier" , async ( ) => {
12- server . use ( http . post ( "https://accounts.spotify.com/api/token" , ( ) =>
13- HttpResponse . json ( { access_token : "TEST_ACCESS_TOKEN" , token_type : "Bearer" , expires_in : 3600 } ) ) )
43+ test ( "stores the tokens and their expiry, and discards the single-use code verifier" , async ( ) => {
44+ server . use ( http . post ( tokenUrl , ( ) => HttpResponse . json ( grantedToken . body ) ) )
1445 localStorage . setItem ( "code_verifier" , "TEST_CODE_VERIFIER" )
46+ const now = Date . now ( )
1547
1648 await expect ( exchangeCodeForToken ( "TEST_AUTH_CODE" ) ) . resolves . toBe ( "TEST_ACCESS_TOKEN" )
1749 expect ( localStorage . getItem ( "access_token" ) ) . toBe ( "TEST_ACCESS_TOKEN" )
50+ expect ( localStorage . getItem ( "refresh_token" ) ) . toBe ( "TEST_REFRESH_TOKEN" )
51+ expect ( localStorage . getItem ( "token_expires_at" ) ) . toBe ( String ( now + HOUR ) )
1852 expect ( localStorage . getItem ( "code_verifier" ) ) . toBeNull ( )
1953 } )
2054
@@ -33,11 +67,124 @@ describe("exchangeCodeForToken", () => {
3367 } )
3468} )
3569
70+ describe ( "refreshAccessToken" , ( ) => {
71+ test ( "exchanges the refresh token using the client ID that logged in" , async ( ) => {
72+ const requests = mockTokenEndpoint ( refresh ( ) )
73+ logIn ( { clientId : "CUSTOM_CLIENT_ID" } )
74+
75+ await expect ( refreshAccessToken ( ) ) . resolves . toBe ( "REFRESHED_ACCESS_TOKEN" )
76+ expect ( Object . fromEntries ( requests [ 0 ] ) ) . toEqual ( {
77+ grant_type : "refresh_token" ,
78+ refresh_token : "TEST_REFRESH_TOKEN" ,
79+ client_id : "CUSTOM_CLIENT_ID" ,
80+ } )
81+ } )
82+
83+ test ( "stores the new access token, its expiry and the rotated refresh token" , async ( ) => {
84+ mockTokenEndpoint ( refresh ( ) )
85+ logIn ( { expiresAt : Date . now ( ) } )
86+ const now = Date . now ( )
87+
88+ await refreshAccessToken ( )
89+
90+ expect ( localStorage . getItem ( "access_token" ) ) . toBe ( "REFRESHED_ACCESS_TOKEN" )
91+ expect ( localStorage . getItem ( "token_expires_at" ) ) . toBe ( String ( now + HOUR ) )
92+ expect ( localStorage . getItem ( "refresh_token" ) ) . toBe ( "ROTATED_REFRESH_TOKEN" )
93+ } )
94+
95+ test ( "keeps the refresh token when Spotify doesn't rotate it" , async ( ) => {
96+ const { refresh_token : _ , ...withoutRefreshToken } = refreshedToken . body
97+ mockTokenEndpoint ( refresh ( withoutRefreshToken ) )
98+ logIn ( )
99+
100+ await refreshAccessToken ( )
101+
102+ expect ( localStorage . getItem ( "refresh_token" ) ) . toBe ( "TEST_REFRESH_TOKEN" )
103+ } )
104+
105+ test ( "shares one request between overlapping refreshes" , async ( ) => {
106+ const requests = mockTokenEndpoint ( refresh ( ) )
107+ logIn ( )
108+
109+ const tokens = await Promise . all ( [ refreshAccessToken ( ) , refreshAccessToken ( ) , refreshAccessToken ( ) ] )
110+
111+ expect ( tokens ) . toEqual ( [ "REFRESHED_ACCESS_TOKEN" , "REFRESHED_ACCESS_TOKEN" , "REFRESHED_ACCESS_TOKEN" ] )
112+ expect ( requests ) . toHaveLength ( 1 )
113+ } )
114+
115+ test ( "refreshes again once the previous refresh has finished" , async ( ) => {
116+ const requests = mockTokenEndpoint ( refresh ( ) )
117+ logIn ( )
118+
119+ await refreshAccessToken ( )
120+ await refreshAccessToken ( )
121+
122+ expect ( requests ) . toHaveLength ( 2 )
123+ expect ( requests [ 1 ] . get ( "refresh_token" ) ) . toBe ( "ROTATED_REFRESH_TOKEN" )
124+ } )
125+
126+ test ( "ends the session when Spotify refuses the refresh token, reporting why but not the token" , async ( ) => {
127+ const reports = captureBugsnagReports ( )
128+ mockTokenEndpoint ( revoke )
129+ logIn ( )
130+
131+ await expect ( refreshAccessToken ( ) ) . rejects . toBeInstanceOf ( SessionExpiredError )
132+ expect ( reports ) . toHaveLength ( 1 )
133+ expect ( reports [ 0 ] . groupingHash ) . toBe ( "Token refresh: Spotify: invalid_grant (Refresh token revoked)" )
134+ expect ( reports [ 0 ] . metadata ) . toEqual ( { oauth : { status : 400 , error : "invalid_grant" , error_description : "Refresh token revoked" } } )
135+ expect ( JSON . stringify ( reports ) ) . not . toContain ( "TEST_REFRESH_TOKEN" )
136+ } )
137+
138+ test ( "ends the session without calling Spotify when no refresh token was stored" , async ( ) => {
139+ const reports = captureBugsnagReports ( )
140+ const requests = mockTokenEndpoint ( refresh ( ) )
141+ localStorage . setItem ( "access_token" , "TEST_ACCESS_TOKEN" )
142+
143+ await expect ( refreshAccessToken ( ) ) . rejects . toBeInstanceOf ( SessionExpiredError )
144+ expect ( requests ) . toHaveLength ( 0 )
145+ expect ( reports ) . toEqual ( [ ] )
146+ } )
147+
148+ test ( "passes on a network failure so the request can be retried" , async ( ) => {
149+ mockTokenEndpoint ( ( ) => HttpResponse . error ( ) )
150+ logIn ( )
151+
152+ await expect ( refreshAccessToken ( ) ) . rejects . toMatchObject ( { code : "ERR_NETWORK" } )
153+ expect ( localStorage . getItem ( "refresh_token" ) ) . toBe ( "TEST_REFRESH_TOKEN" )
154+ } )
155+ } )
156+
157+ describe ( "currentAccessToken" , ( ) => {
158+ test ( "is the stored token while it has time left" , async ( ) => {
159+ const requests = mockTokenEndpoint ( refresh ( ) )
160+ logIn ( )
161+ vi . setSystemTime ( Date . now ( ) + HOUR - 2 * 60 * 1000 )
162+
163+ await expect ( currentAccessToken ( ) ) . resolves . toBe ( "TEST_ACCESS_TOKEN" )
164+ expect ( requests ) . toHaveLength ( 0 )
165+ } )
166+
167+ test ( "is refreshed in the last minute before expiry" , async ( ) => {
168+ mockTokenEndpoint ( refresh ( ) )
169+ logIn ( )
170+ vi . setSystemTime ( Date . now ( ) + HOUR - 30 * 1000 )
171+
172+ await expect ( currentAccessToken ( ) ) . resolves . toBe ( "REFRESHED_ACCESS_TOKEN" )
173+ } )
174+
175+ test ( "is the stored token for a login from before refresh tokens were kept" , async ( ) => {
176+ const requests = mockTokenEndpoint ( refresh ( ) )
177+ localStorage . setItem ( "access_token" , "TEST_ACCESS_TOKEN" )
178+
179+ await expect ( currentAccessToken ( ) ) . resolves . toBe ( "TEST_ACCESS_TOKEN" )
180+ expect ( requests ) . toHaveLength ( 0 )
181+ } )
182+ } )
183+
36184describe ( "logout" , ( ) => {
37185 test ( "clears stored credentials and returns to the current page" , ( ) => {
38- localStorage . setItem ( "access_token" , "TEST_ACCESS_TOKEN" )
186+ logIn ( { clientId : "CUSTOM_CLIENT_ID" } )
39187 localStorage . setItem ( "code_verifier" , "TEST_CODE_VERIFIER" )
40- localStorage . setItem ( "client_id" , "CUSTOM_CLIENT_ID" )
41188
42189 logout ( )
43190
0 commit comments