go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/auth_state_provider/auth_state_provider.test.tsx (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 16 import { act, cleanup, render } from '@testing-library/react'; 17 import { DateTime } from 'luxon'; 18 import { destroy } from 'mobx-state-tree'; 19 20 import * as authStateLib from '@/common/api/auth_state'; 21 import { Store, StoreInstance, StoreProvider } from '@/common/store'; 22 import { timeout } from '@/generic_libs/tools/utils'; 23 24 import { 25 AuthStateProvider, 26 useAuthState, 27 useGetAccessToken, 28 useGetIdToken, 29 } from './auth_state_provider'; 30 31 interface TokenConsumerProps { 32 readonly renderCallback: ( 33 getIdToken: ReturnType<typeof useGetIdToken>, 34 getAccessToken: ReturnType<typeof useGetAccessToken>, 35 ) => void; 36 } 37 38 function TokenConsumer({ renderCallback }: TokenConsumerProps) { 39 const getIdToken = useGetIdToken(); 40 const getAccessToken = useGetAccessToken(); 41 renderCallback(getIdToken, getAccessToken); 42 43 return <></>; 44 } 45 46 interface IdentityConsumerProps { 47 readonly renderCallback: (identity: string) => void; 48 } 49 50 function IdentityConsumer({ renderCallback }: IdentityConsumerProps) { 51 const authState = useAuthState(); 52 renderCallback(authState.identity); 53 54 return <></>; 55 } 56 57 jest.mock('@/common/api/auth_state', () => { 58 return createSelectiveMockFromModule< 59 typeof import('@/common/api/auth_state') 60 >('@/common/api/auth_state', ['queryAuthState']); 61 }); 62 63 describe('AuthStateProvider', () => { 64 let store: StoreInstance; 65 let queryAuthStateSpy: jest.MockedFunction< 66 typeof authStateLib.queryAuthState 67 >; 68 69 beforeEach(() => { 70 store = Store.create({}); 71 jest.useFakeTimers(); 72 queryAuthStateSpy = jest.mocked(authStateLib.queryAuthState); 73 }); 74 afterEach(() => { 75 cleanup(); 76 queryAuthStateSpy.mockReset(); 77 destroy(store); 78 jest.useRealTimers(); 79 }); 80 81 it('should refresh auth state correctly', async () => { 82 const tokenConsumerCBSpy = jest.fn( 83 ( 84 _getIdToken: ReturnType<typeof useGetIdToken>, 85 _getAccessToken: ReturnType<typeof useGetAccessToken>, 86 ) => {}, 87 ); 88 const identityConsumerCBSpy = jest.fn((_identity: string) => {}); 89 const initialAuthState = { 90 identity: 'identity-1', 91 idToken: 'id-token-1', 92 accessToken: 'access-token-1', 93 accessTokenExpiry: DateTime.now().plus({ minute: 20 }).toSeconds(), 94 }; 95 const firstQueryResponse = { 96 identity: 'identity-1', 97 idToken: 'id-token-2', 98 accessToken: 'access-token-2', 99 accessTokenExpiry: DateTime.now().plus({ minute: 60 }).toSeconds(), 100 }; 101 queryAuthStateSpy.mockResolvedValue( 102 // Resolve after 1s. 103 timeout(1000).then(() => firstQueryResponse), 104 ); 105 106 render( 107 <QueryClientProvider client={new QueryClient()}> 108 <StoreProvider value={store}> 109 <AuthStateProvider initialValue={initialAuthState}> 110 <IdentityConsumer renderCallback={identityConsumerCBSpy} /> 111 <TokenConsumer renderCallback={tokenConsumerCBSpy} /> 112 </AuthStateProvider> 113 </StoreProvider> 114 </QueryClientProvider>, 115 ); 116 117 // Advance timer by 500ms, before the first query returns. 118 await act(() => jest.advanceTimersByTimeAsync(500)); 119 // The first query should've been sent immediately. Even when the initial 120 // auth token hasn't expired yet. This is necessary because the initial 121 // value could've been an outdated cache (if user signed in/out in a 122 // different tab) 123 expect(queryAuthStateSpy).toHaveBeenCalledTimes(1); 124 expect(identityConsumerCBSpy.mock.calls.length).toStrictEqual(1); 125 expect(identityConsumerCBSpy.mock.lastCall?.[0]).toStrictEqual( 126 'identity-1', 127 ); 128 expect(tokenConsumerCBSpy.mock.calls.length).toStrictEqual(1); 129 expect(await tokenConsumerCBSpy.mock.lastCall?.[0]()).toStrictEqual( 130 'id-token-1', 131 ); 132 expect(await tokenConsumerCBSpy.mock.lastCall?.[1]()).toStrictEqual( 133 'access-token-1', 134 ); 135 expect(store.authState.value).toEqual(initialAuthState); 136 expect(authStateLib.getAuthStateCacheSync()).toBeNull(); 137 138 // Advance timer by 40s, after the initial query returns but before the 139 // initial token expires. 140 await act(() => jest.advanceTimersByTimeAsync(40000)); 141 // Update tokens should not trigger context updates. 142 expect(identityConsumerCBSpy.mock.calls.length).toStrictEqual(1); 143 expect(identityConsumerCBSpy.mock.lastCall?.[0]).toStrictEqual( 144 'identity-1', 145 ); 146 expect(tokenConsumerCBSpy.mock.calls.length).toStrictEqual(1); 147 // The token getters can still return the latest tokens. 148 expect(await tokenConsumerCBSpy.mock.lastCall?.[0]()).toStrictEqual( 149 'id-token-2', 150 ); 151 expect(await tokenConsumerCBSpy.mock.lastCall?.[1]()).toStrictEqual( 152 'access-token-2', 153 ); 154 expect(store.authState.value).toEqual(firstQueryResponse); 155 expect(authStateLib.getAuthStateCacheSync()).toEqual(firstQueryResponse); 156 157 const secondQueryResponse = { 158 identity: 'identity-2', 159 idToken: 'id-token-3', 160 accessToken: 'access-token-3', 161 accessTokenExpiry: DateTime.fromSeconds( 162 firstQueryResponse.accessTokenExpiry, 163 ) 164 .plus({ minute: 29 }) 165 .toSeconds(), 166 }; 167 queryAuthStateSpy.mockResolvedValue(secondQueryResponse); 168 169 // Advance the timer to just before the first queried token is about to 170 // expire. 171 await act(() => 172 jest.advanceTimersByTimeAsync( 173 firstQueryResponse.accessTokenExpiry * 1000 - Date.now() - 10000, 174 ), 175 ); 176 177 expect(queryAuthStateSpy).toHaveBeenCalledTimes(2); 178 // Update identity should trigger context updates. 179 expect(identityConsumerCBSpy.mock.calls.length).toStrictEqual(2); 180 expect(identityConsumerCBSpy.mock.lastCall?.[0]).toStrictEqual( 181 'identity-2', 182 ); 183 expect(tokenConsumerCBSpy.mock.calls.length).toStrictEqual(2); 184 // The token getters can still return the latest tokens. 185 expect(await tokenConsumerCBSpy.mock.lastCall?.[0]()).toStrictEqual( 186 'id-token-3', 187 ); 188 expect(await tokenConsumerCBSpy.mock.lastCall?.[1]()).toStrictEqual( 189 'access-token-3', 190 ); 191 expect(store.authState.value).toEqual(secondQueryResponse); 192 expect(authStateLib.getAuthStateCacheSync()).toEqual(secondQueryResponse); 193 194 const thirdQueryResponse = { 195 identity: 'identity-2', 196 idToken: 'id-token-4', 197 accessToken: 'access-token-4', 198 accessTokenExpiry: DateTime.fromSeconds( 199 secondQueryResponse.accessTokenExpiry, 200 ) 201 .plus({ minute: 59 }) 202 .toSeconds(), 203 }; 204 queryAuthStateSpy.mockResolvedValue(thirdQueryResponse); 205 206 // Advance the timer to just before the second queried token is about to 207 // expire. 208 await act(() => 209 jest.advanceTimersByTimeAsync( 210 secondQueryResponse.accessTokenExpiry * 1000 - Date.now() - 10000, 211 ), 212 ); 213 214 expect(queryAuthStateSpy).toHaveBeenCalledTimes(3); 215 // Update identity should trigger context updates. 216 expect(identityConsumerCBSpy.mock.calls.length).toStrictEqual(2); 217 expect(identityConsumerCBSpy.mock.lastCall?.[0]).toStrictEqual( 218 'identity-2', 219 ); 220 expect(tokenConsumerCBSpy.mock.calls.length).toStrictEqual(2); 221 // The token getters can still return the latest tokens. 222 expect(await tokenConsumerCBSpy.mock.lastCall?.[0]()).toStrictEqual( 223 'id-token-4', 224 ); 225 expect(await tokenConsumerCBSpy.mock.lastCall?.[1]()).toStrictEqual( 226 'access-token-4', 227 ); 228 expect(store.authState.value).toEqual(thirdQueryResponse); 229 expect(authStateLib.getAuthStateCacheSync()).toEqual(thirdQueryResponse); 230 }); 231 232 it('should not return expired tokens', async () => { 233 const tokenConsumerCBSpy = jest.fn( 234 ( 235 _getIdToken: ReturnType<typeof useGetIdToken>, 236 _getAccessToken: ReturnType<typeof useGetAccessToken>, 237 ) => {}, 238 ); 239 const initialAuthState = { 240 identity: 'identity-1', 241 idToken: 'id-token-1', 242 accessToken: 'access-token-1', 243 accessTokenExpiry: DateTime.now().plus({ minute: -20 }).toSeconds(), 244 }; 245 queryAuthStateSpy.mockResolvedValueOnce({ 246 identity: 'identity-1', 247 idToken: 'id-token-2', 248 accessToken: 'access-token-2', 249 accessTokenExpiry: DateTime.now().plus({ minute: -10 }).toSeconds(), 250 }); 251 queryAuthStateSpy.mockResolvedValueOnce({ 252 identity: 'identity-1', 253 idToken: 'id-token-3', 254 accessToken: 'access-token-3', 255 accessTokenExpiry: DateTime.now().plus({ minute: 10 }).toSeconds(), 256 }); 257 258 render( 259 <QueryClientProvider client={new QueryClient()}> 260 <StoreProvider value={store}> 261 <AuthStateProvider initialValue={initialAuthState}> 262 <TokenConsumer renderCallback={tokenConsumerCBSpy} /> 263 </AuthStateProvider> 264 </StoreProvider> 265 </QueryClientProvider>, 266 ); 267 268 expect(tokenConsumerCBSpy.mock.calls.length).toStrictEqual(1); 269 const getIdTokenPromise = tokenConsumerCBSpy.mock.lastCall![0](); 270 const getAccessTokenPromise = tokenConsumerCBSpy.mock.lastCall![1](); 271 272 // Ensure that the non-expired tokens haven't been returned when calling the 273 // token getters. 274 expect(queryAuthStateSpy.mock.calls.length).toBeLessThan(2); 275 276 // Advance timer by 1min. 277 await act(() => jest.advanceTimersByTimeAsync(60000)); 278 expect(await getIdTokenPromise).toStrictEqual('id-token-3'); 279 expect(await getAccessTokenPromise).toStrictEqual('access-token-3'); 280 }); 281 282 it('should not return tokens for another identity', async () => { 283 const tokenConsumerCBSpy = jest.fn( 284 ( 285 _getIdToken: ReturnType<typeof useGetIdToken>, 286 _getAccessToken: ReturnType<typeof useGetAccessToken>, 287 ) => {}, 288 ); 289 const initialAuthState = { 290 identity: 'identity-1', 291 idToken: 'id-token-1', 292 accessToken: 'access-token-1', 293 accessTokenExpiry: DateTime.now().plus({ minute: -20 }).toSeconds(), 294 }; 295 queryAuthStateSpy.mockResolvedValueOnce({ 296 identity: 'identity-1', 297 idToken: 'id-token-2', 298 accessToken: 'access-token-2', 299 accessTokenExpiry: DateTime.now().plus({ minute: -10 }).toSeconds(), 300 }); 301 queryAuthStateSpy.mockResolvedValueOnce({ 302 identity: 'identity-2', 303 idToken: 'id-token-3', 304 accessToken: 'access-token-3', 305 accessTokenExpiry: DateTime.now().plus({ minute: 10 }).toSeconds(), 306 }); 307 308 render( 309 <QueryClientProvider client={new QueryClient()}> 310 <StoreProvider value={store}> 311 <AuthStateProvider initialValue={initialAuthState}> 312 <TokenConsumer renderCallback={tokenConsumerCBSpy} /> 313 </AuthStateProvider> 314 </StoreProvider> 315 </QueryClientProvider>, 316 ); 317 318 expect(tokenConsumerCBSpy.mock.calls.length).toStrictEqual(1); 319 let resolvedIdToken: string | null = null; 320 let resolvedAccessToken: string | null = null; 321 tokenConsumerCBSpy.mock 322 .lastCall![0]() 323 .then((tok) => (resolvedIdToken = tok)); 324 tokenConsumerCBSpy.mock 325 .lastCall![1]() 326 .then((tok) => (resolvedAccessToken = tok)); 327 328 // Ensure that the non-expired tokens haven't been returned when calling the 329 // token getters. 330 expect(queryAuthStateSpy.mock.calls.length).toBeLessThan(2); 331 332 queryAuthStateSpy.mockResolvedValue({ 333 identity: 'identity-2', 334 idToken: 'id-token-3', 335 accessToken: 'access-token-3', 336 accessTokenExpiry: DateTime.now().plus({ minute: 10 }).toSeconds(), 337 }); 338 339 // Advance timer by several hours. 340 await act(() => jest.advanceTimersByTimeAsync(3600000)); 341 await act(() => jest.advanceTimersByTimeAsync(3600000)); 342 await act(() => jest.advanceTimersByTimeAsync(3600000)); 343 344 expect(resolvedIdToken).toBeNull(); 345 expect(resolvedAccessToken).toBeNull(); 346 }); 347 348 it('should not update auth state too frequently when tokens are very short lived', async () => { 349 queryAuthStateSpy.mockImplementation(async () => { 350 return { 351 identity: 'identity', 352 idToken: 'id-token', 353 accessToken: 'access-token', 354 idTokenExpiry: DateTime.now().plus({ seconds: 1 }).toSeconds(), 355 }; 356 }); 357 358 render( 359 <QueryClientProvider client={new QueryClient()}> 360 <StoreProvider value={store}> 361 <AuthStateProvider 362 initialValue={{ 363 identity: 'identity-1', 364 idToken: 'id-token-1', 365 accessToken: 'access-token-1', 366 idTokenExpiry: DateTime.now().plus({ seconds: 1 }).toSeconds(), 367 }} 368 > 369 <></> 370 </AuthStateProvider> 371 </StoreProvider> 372 </QueryClientProvider>, 373 ); 374 375 // In the first 3 seconds, there should only be one query. 376 await act(() => jest.advanceTimersByTimeAsync(1000)); 377 expect(queryAuthStateSpy).toHaveBeenCalledTimes(1); 378 await act(() => jest.advanceTimersByTimeAsync(1000)); 379 expect(queryAuthStateSpy).toHaveBeenCalledTimes(1); 380 await act(() => jest.advanceTimersByTimeAsync(1000)); 381 expect(queryAuthStateSpy).toHaveBeenCalledTimes(1); 382 383 // Move the timer to when the next query is sent. 384 await act(() => jest.advanceTimersToNextTimerAsync()); 385 expect(queryAuthStateSpy).toHaveBeenCalledTimes(2); 386 387 // In the next 3 seconds, no more query should be sent. 388 await act(() => jest.advanceTimersByTimeAsync(1000)); 389 expect(queryAuthStateSpy).toHaveBeenCalledTimes(2); 390 await act(() => jest.advanceTimersByTimeAsync(1000)); 391 expect(queryAuthStateSpy).toHaveBeenCalledTimes(2); 392 await act(() => jest.advanceTimersByTimeAsync(1000)); 393 expect(queryAuthStateSpy).toHaveBeenCalledTimes(2); 394 }); 395 396 it("should not refetch auth-state when tokens don't expire", async () => { 397 queryAuthStateSpy.mockImplementation(async () => { 398 return { 399 identity: 'identity', 400 idToken: 'id-token', 401 accessToken: 'access-token', 402 }; 403 }); 404 405 render( 406 <QueryClientProvider client={new QueryClient()}> 407 <StoreProvider value={store}> 408 <AuthStateProvider 409 initialValue={{ 410 identity: 'identity-1', 411 idToken: 'id-token-1', 412 accessToken: 'access-token-1', 413 }} 414 > 415 <></> 416 </AuthStateProvider> 417 </StoreProvider> 418 </QueryClientProvider>, 419 ); 420 421 // In the first 3 seconds, there should be only one query. 422 await act(() => jest.advanceTimersByTimeAsync(1000)); 423 expect(queryAuthStateSpy).toHaveBeenCalledTimes(1); 424 425 // Move the timer by several hours, no more query should be sent. 426 await act(() => jest.advanceTimersByTimeAsync(3600000)); 427 await act(() => jest.advanceTimersByTimeAsync(3600000)); 428 await act(() => jest.advanceTimersByTimeAsync(3600000)); 429 expect(queryAuthStateSpy).toHaveBeenCalledTimes(1); 430 }); 431 });