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  });