fix: refreshing statuses

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-03-15 17:25:30 +01:00
parent aaab35ee17
commit 1a557c1439
4 changed files with 175 additions and 46 deletions
+8 -16
View File
@@ -13,28 +13,20 @@ export function CookieAuthRefreshProvider(
// @public
export type CookieAuthRefreshProviderProps = {
pluginId: string;
options?: {
path?: string;
};
path?: string;
children: ReactNode;
};
// @public
export function useCookieAuthRefresh(params: {
export function useCookieAuthRefresh(options: {
pluginId: string;
options?: {
path?: string;
};
path?: string;
}): {
loading: boolean;
error: Error | undefined;
value:
| {
expiresAt: string;
}
| undefined;
retry: (...args: unknown[]) => Promise<{
status: 'loading' | 'error' | 'success';
error?: Error;
result?: {
expiresAt: string;
}>;
};
retry: () => void;
};
```
@@ -27,11 +27,8 @@ import { useCookieAuthRefresh } from '../../hooks';
export type CookieAuthRefreshProviderProps = {
// The plugin ID to used for discovering the API origin
pluginId: string;
// Options for configuring the refresh cookie endpoint
options?: {
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
};
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
// The children to render when the refresh is successful
children: ReactNode;
};
@@ -43,17 +40,17 @@ export type CookieAuthRefreshProviderProps = {
export function CookieAuthRefreshProvider(
props: CookieAuthRefreshProviderProps,
): JSX.Element {
const { children, ...params } = props;
const { children, ...options } = props;
const app = useApp();
const { Progress } = app.getComponents();
const { loading, error, retry } = useCookieAuthRefresh(params);
const { status, error, retry } = useCookieAuthRefresh(options);
if (loading) {
if (status === 'loading') {
return <Progress />;
}
if (error) {
if (status === 'error' && error) {
return (
<ErrorPanel error={error}>
<Button variant="outlined" onClick={retry}>
@@ -54,7 +54,9 @@ describe('useCookieAuthRefresh', () => {
jest.useRealTimers();
});
it('should return a loading status when the refresh is in progress', () => {
it('should return a loading status when the refresh is in progress first time', () => {
const error = new Error('Failed to get cookie');
const { result } = renderHook(
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
{
@@ -64,7 +66,7 @@ describe('useCookieAuthRefresh', () => {
[
fetchApiRef,
{
fetch: jest.fn(),
fetch: jest.fn().mockRejectedValue(error),
},
],
[storageApiRef, storageApiMock],
@@ -77,7 +79,105 @@ describe('useCookieAuthRefresh', () => {
},
);
expect(result.current.loading).toBeTruthy();
expect(result.current.status).toBe('loading');
expect(result.current.error).toBeUndefined();
expect(result.current.result).toBeUndefined();
});
it('should return a loading status when retrying without previous success', async () => {
const error = new Error('Failed to get cookie');
const { result } = renderHook(
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
{
wrapper: ({ children }) => (
<TestApiProvider
apis={[
[
fetchApiRef,
{
fetch: jest
.fn()
.mockRejectedValueOnce(error)
.mockReturnValue(new Promise(() => {})),
},
],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
},
);
expect(result.current.status).toBe('loading');
expect(result.current.error).toBeUndefined();
expect(result.current.result).toBeUndefined();
await waitFor(() => expect(result.current.status).toBe('error'));
expect(result.current.result).toBeUndefined();
expect(result.current.error).toStrictEqual(error);
result.current.retry();
await waitFor(() => expect(result.current.status).toBe('loading'));
expect(result.current.result).toBeUndefined();
expect(result.current.error).toStrictEqual(error);
});
it('should return a loading status when retrying with previous success', async () => {
const error = new Error('Failed to get cookie');
const { result } = renderHook(
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
{
wrapper: ({ children }) => (
<TestApiProvider
apis={[
[
fetchApiRef,
{
fetch: jest
.fn()
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue({ expiresAt }),
})
.mockRejectedValueOnce(error)
.mockReturnValue(new Promise(() => {})),
},
],
[storageApiRef, storageApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
},
);
expect(result.current.status).toBe('loading');
expect(result.current.error).toBeUndefined();
expect(result.current.result).toBeUndefined();
await waitFor(() => expect(result.current.status).toBe('success'));
expect(result.current.result).toMatchObject({ expiresAt });
expect(result.current.error).toBeUndefined();
result.current.retry();
await waitFor(() => expect(result.current.status).toBe('error'));
expect(result.current.result).toMatchObject({ expiresAt });
expect(result.current.error).toStrictEqual(error);
result.current.retry();
await waitFor(() => expect(result.current.status).toBe('loading'));
expect(result.current.result).toMatchObject({ expiresAt });
expect(result.current.error).toStrictEqual(error);
});
it('should return an error status when the refresh has failed', async () => {
@@ -133,7 +233,7 @@ describe('useCookieAuthRefresh', () => {
),
);
expect(result.current.value).toMatchObject({ expiresAt });
expect(result.current.result).toMatchObject({ expiresAt });
});
it('should cancel the refresh when a message is received from another tab', async () => {
@@ -197,7 +297,7 @@ describe('useCookieAuthRefresh', () => {
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1));
expect(result.current.value).toMatchObject({ expiresAt });
expect(result.current.result).toMatchObject({ expiresAt });
unmount();
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useMemo } from 'react';
import {
discoveryApiRef,
fetchApiRef,
@@ -27,18 +27,22 @@ import { ResponseError } from '@backstage/errors';
/**
* @public
* A hook that will refresh the cookie when it is about to expire.
* @param options - Options for configuring the refresh cookie endpoint
*/
export function useCookieAuthRefresh(params: {
// The plugin ID to used for discovering the API origin
export function useCookieAuthRefresh(options: {
// The plugin id to used for discovering the API origin
pluginId: string;
// Options for configuring the refresh cookie endpoint
options?: {
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
}): {
status: 'loading' | 'error' | 'success';
error?: Error;
result?: {
expiresAt: string;
};
}) {
const { pluginId, options: { path = '/cookie' } = {} } = params;
retry: () => void;
} {
const { pluginId, path = '/cookie' } = options ?? {};
const fetchApi = useApi(fetchApiRef);
const storageApi = useApi(storageApiRef);
const discoveryApi = useApi(discoveryApiRef);
@@ -60,10 +64,10 @@ export function useCookieAuthRefresh(params: {
useMountEffect(actions.execute);
const refresh = useCallback(
(options: { expiresAt: string }) => {
(params: { expiresAt: string }) => {
// Randomize the refreshing margin to avoid all tabs refreshing at the same time
const margin = (1 + 3 * Math.random()) * 60000;
const delay = Date.parse(options.expiresAt) - Date.now() - margin;
const delay = Date.parse(params.expiresAt) - Date.now() - margin;
const timeout = setTimeout(actions.execute, delay);
return () => clearTimeout(timeout);
},
@@ -71,7 +75,10 @@ export function useCookieAuthRefresh(params: {
);
useEffect(() => {
if (!state.result) return () => {};
// Only start the refresh process if we have a successful response
if (state.status !== 'success' || !state.result) {
return () => {};
}
store.set('expiresAt', state.result.expiresAt);
@@ -90,10 +97,43 @@ export function useCookieAuthRefresh(params: {
};
}, [state, refresh, store]);
const status = useMemo(() => {
// Initialising
if (state.status === 'not-executed') {
return 'loading';
}
// First refresh or retrying without any success before
// Possible states transitions:
// e.g. not-executed -> loading (first-refresh)
// e.g. not-executed -> loading (first-refresh) -> error -> loading (manual-retry)
if (state.status === 'loading' && !state.result) {
return 'loading';
}
// Retrying after having succeeding at least once
// Current states is: { status: 'loading', result: {...}, error: undefined | Error }
// e.g. not-executed -> loading (first-refresh) -> success -> loading (scheduled-refresh) -> error -> loading (manual-retry)
if (state.status === 'loading' && state.error) {
return 'loading';
}
// Something went wrong during the any situation of a refresh
if (state.status === 'error' && state.error) {
return 'error';
}
return 'success';
}, [state]);
const retry = useCallback(() => {
actions.execute();
}, [actions]);
return {
loading: state.status === 'loading',
retry,
status,
result: state.result,
error: state.error,
value: state.result,
retry: actions.execute,
};
}