refactor: apply review suggestions

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-03-13 14:14:34 +01:00
parent 40817356a8
commit b806745876
15 changed files with 315 additions and 182 deletions
@@ -19,10 +19,15 @@ import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CookieAuthRefreshProvider } from './CookieAuthRefreshProvider';
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import { createApiRef } from '@backstage/core-plugin-api';
import { AuthApi } from '../../types';
import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api';
describe('CookieAuthRefreshProvider', () => {
const discoveryApiMock = {
getBaseUrl: jest
.fn()
.mockResolvedValue('http://localhost:7000/techdocs/api'),
};
function getExpiresAtInFuture() {
const tenMinutesInMilliseconds = 10 * 60 * 1000;
return new Date(Date.now() + tenMinutesInMilliseconds).toISOString();
@@ -35,14 +40,18 @@ describe('CookieAuthRefreshProvider', () => {
}));
it('should render a progress bar', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const apiMock = {
getCookie: jest.fn().mockReturnValue(new Promise(() => {})),
const fetchApiMock = {
fetch: jest.fn().mockReturnValue(new Promise(() => {})),
};
await renderInTestApp(
<TestApiProvider apis={[[apiRef, apiMock]]}>
<CookieAuthRefreshProvider apiRef={apiRef}>
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
<CookieAuthRefreshProvider pluginId="techdocs">
<div>Test Content</div>
</CookieAuthRefreshProvider>
</TestApiProvider>,
@@ -51,16 +60,20 @@ describe('CookieAuthRefreshProvider', () => {
expect(screen.getByTestId('progress')).toBeInTheDocument();
});
it('should render a error panel', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
it('should render an error panel', async () => {
const error = new Error('Failed to get cookie');
const apiMock = {
getCookie: jest.fn().mockRejectedValue(error),
const fetchApiMock = {
fetch: jest.fn().mockRejectedValue(error),
};
await renderInTestApp(
<TestApiProvider apis={[[apiRef, apiMock]]}>
<CookieAuthRefreshProvider apiRef={apiRef}>
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
<CookieAuthRefreshProvider pluginId="techdocs">
<div>Test Content</div>
</CookieAuthRefreshProvider>
</TestApiProvider>,
@@ -70,29 +83,46 @@ describe('CookieAuthRefreshProvider', () => {
});
it('should call the api again when retry is clicked', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const error = new Error('Failed to get cookie');
const apiMock = {
getCookie: jest.fn().mockRejectedValueOnce(error).mockResolvedValue({
expiresAt: getExpiresAtInFuture(),
}),
const fetchApiMock = {
fetch: jest
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
expiresAt: getExpiresAtInFuture(),
}),
}),
};
await renderInTestApp(
<TestApiProvider apis={[[apiRef, apiMock]]}>
<CookieAuthRefreshProvider apiRef={apiRef}>
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
<CookieAuthRefreshProvider pluginId="techdocs">
<div>Test Content</div>
</CookieAuthRefreshProvider>
</TestApiProvider>,
);
expect(apiMock.getCookie).toHaveBeenCalledTimes(1);
await waitFor(() =>
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/techdocs/api/cookie',
{ credentials: 'include' },
),
);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
expect(screen.getByText(error.message)).toBeInTheDocument();
await userEvent.click(screen.getByText('Retry'));
expect(apiMock.getCookie).toHaveBeenCalledTimes(2);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2);
await waitFor(() =>
expect(screen.getByText('Test Content')).toBeInTheDocument(),
@@ -100,18 +130,23 @@ describe('CookieAuthRefreshProvider', () => {
});
it('should render the children', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const apiMock = {
getCookie: jest.fn().mockResolvedValue({
expiresAt: {
const fetchApiMock = {
fetch: jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
expiresAt: getExpiresAtInFuture(),
},
}),
}),
};
await renderInTestApp(
<TestApiProvider apis={[[apiRef, apiMock]]}>
<CookieAuthRefreshProvider apiRef={apiRef}>
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
<CookieAuthRefreshProvider pluginId="techdocs">
<div>Test Content</div>
</CookieAuthRefreshProvider>
</TestApiProvider>,
@@ -16,27 +16,31 @@
import React, { ReactNode } from 'react';
import { ErrorPanel } from '@backstage/core-components';
import { ApiRef, useApp } from '@backstage/core-plugin-api';
import { useApp } from '@backstage/core-plugin-api';
import { Button } from '@material-ui/core';
import { useCookieAuthRefresh } from '../../hooks';
import { AuthApi } from '../../types';
import { useCookieAuthRefresh, CookieAuthRefreshOptions } from '../../hooks';
/**
* @public
* Props for the {@link CookieAuthRefreshProvider} component.
*/
export type CookieAuthRefreshProviderProps = CookieAuthRefreshOptions & {
// The children to render when the refresh is successful
children: ReactNode;
};
/**
* @public
* A provider that will refresh the cookie when it is about to expire.
* It receives an `apiRef` and `children` as props, and expects that apiRef extends the `AuthApi` interface.
*/
export function CookieAuthRefreshProvider<T extends AuthApi>({
apiRef,
export function CookieAuthRefreshProvider({
children,
}: {
apiRef: ApiRef<T>;
children: ReactNode;
}) {
...rest
}: CookieAuthRefreshProviderProps) {
const app = useApp();
const { Progress } = app.getComponents();
const { state, actions } = useCookieAuthRefresh({ apiRef });
const { state, actions } = useCookieAuthRefresh(rest);
if (state.status === 'error' && state.error) {
return (
@@ -14,4 +14,7 @@
* limitations under the License.
*/
export { CookieAuthRefreshProvider } from './CookieAuthRefreshProvider';
export {
CookieAuthRefreshProvider,
type CookieAuthRefreshProviderProps,
} from './CookieAuthRefreshProvider';
@@ -14,4 +14,7 @@
* limitations under the License.
*/
export { useCookieAuthRefresh } from './useCookieAuthRefresh';
export {
useCookieAuthRefresh,
type CookieAuthRefreshOptions,
} from './useCookieAuthRefresh';
@@ -16,17 +16,29 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { createApiRef } from '@backstage/core-plugin-api';
import { fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api';
import { TestApiProvider } from '@backstage/test-utils';
import { useCookieAuthRefresh } from './useCookieAuthRefresh';
import { AuthApi } from '../../types';
describe('useCookieAuthRefresh', () => {
const discoveryApiMock = {
getBaseUrl: jest
.fn()
.mockResolvedValue('http://localhost:7000/techdocs/api'),
};
const now = 1710316886171;
const tenMinutesInMilliseconds = 10 * 60 * 1000;
const tenMinutesFromNowInMilliseconds = now + tenMinutesInMilliseconds;
const expiresAt = new Date(tenMinutesFromNowInMilliseconds).toISOString();
const fetchApiMock = {
fetch: jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ expiresAt }),
}),
};
type Listener = (event: { data: any }) => void;
let listeners: Listener[];
@@ -34,6 +46,7 @@ describe('useCookieAuthRefresh', () => {
beforeEach(() => {
jest.useFakeTimers({ now });
jest.clearAllMocks();
listeners = [];
channelMock = {
postMessage: jest.fn((message: any) => {
@@ -58,32 +71,53 @@ describe('useCookieAuthRefresh', () => {
});
it('should return a loading status when the refresh is in progress', () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const apiMock = {
getCookie: jest.fn(),
};
const { result } = renderHook(() => useCookieAuthRefresh({ apiRef }), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[apiRef, apiMock]]}>{children}</TestApiProvider>
),
});
const { result } = renderHook(
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
{
wrapper: ({ children }) => (
<TestApiProvider
apis={[
[
fetchApiRef,
{
fetch: jest.fn(),
},
],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
},
);
expect(result.current.state.status).toBe('loading');
});
it('should return an error status when the refresh has failed', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const error = new Error('Failed to get cookie');
const apiMock = {
getCookie: jest.fn().mockRejectedValue(error),
};
const { result } = renderHook(() => useCookieAuthRefresh({ apiRef }), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[apiRef, apiMock]]}>{children}</TestApiProvider>
),
});
const { result } = renderHook(
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
{
wrapper: ({ children }) => (
<TestApiProvider
apis={[
[
fetchApiRef,
{
fetch: jest.fn().mockRejectedValue(error),
},
],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
},
);
await waitFor(() => expect(result.current.state.status).toBe('error'));
@@ -91,38 +125,48 @@ describe('useCookieAuthRefresh', () => {
});
it('should call the api to get the cookie and use it', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const apiMock = {
getCookie: jest.fn().mockResolvedValue({ expiresAt }),
};
const { result } = renderHook(() => useCookieAuthRefresh({ apiRef }), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[apiRef, apiMock]]}>{children}</TestApiProvider>
),
});
expect(apiMock.getCookie).toHaveBeenCalled();
const { result } = renderHook(
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
{
wrapper: ({ children }) => (
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
},
);
await waitFor(() =>
expect(result.current.state.result).toMatchObject({ expiresAt }),
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/techdocs/api/cookie',
{ credentials: 'include' },
),
);
expect(result.current.state.result).toMatchObject({ expiresAt });
});
it('should send a message to other tabs when the cookie is refreshed', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const apiMock = {
getCookie: jest.fn().mockResolvedValue({ expiresAt }),
};
renderHook(() => useCookieAuthRefresh({ apiRef }), {
renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[apiRef, apiMock]]}>{children}</TestApiProvider>
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
});
expect(global.BroadcastChannel).toHaveBeenCalledWith(
'auth-test-auth-cookie-channel',
'techdocs-auth-cookie-channel',
);
await waitFor(() =>
@@ -139,14 +183,16 @@ describe('useCookieAuthRefresh', () => {
});
it('should cancel the refresh when a message is received from another tab', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const apiMock = {
getCookie: jest.fn().mockResolvedValue({ expiresAt }),
};
renderHook(() => useCookieAuthRefresh({ apiRef }), {
renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[apiRef, apiMock]]}>{children}</TestApiProvider>
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
});
@@ -169,37 +215,35 @@ describe('useCookieAuthRefresh', () => {
jest.advanceTimersByTime(tenMinutesInMilliseconds);
// should not call the api
expect(apiMock.getCookie).toHaveBeenCalledTimes(1);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
// advance the timers in more 10 minutes to match the new expires at
jest.advanceTimersByTime(tenMinutesInMilliseconds);
// should call the api
await waitFor(() => expect(apiMock.getCookie).toHaveBeenCalledTimes(2));
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2));
});
it('should cancel the refresh when the component is unmounted', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const apiMock = {
getCookie: jest.fn().mockResolvedValue({ expiresAt }),
};
const { result, unmount } = renderHook(
() => useCookieAuthRefresh({ apiRef }),
() => useCookieAuthRefresh({ pluginId: 'techdocs' }),
{
wrapper: ({ children }) => (
<TestApiProvider apis={[[apiRef, apiMock]]}>
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
},
);
expect(apiMock.getCookie).toHaveBeenCalledTimes(1);
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(result.current.state.result).toMatchObject({ expiresAt }),
);
expect(result.current.state.result).toMatchObject({ expiresAt });
unmount();
@@ -213,27 +257,31 @@ describe('useCookieAuthRefresh', () => {
jest.advanceTimersByTime(tenMinutesInMilliseconds);
// should not call the api after unmount
await waitFor(() => expect(apiMock.getCookie).not.toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(fetchApiMock.fetch).not.toHaveBeenCalledTimes(2),
);
});
it('should refresh the cookie when it is about to expire', async () => {
const apiRef = createApiRef<AuthApi>({ id: 'auth-test' });
const apiMock = {
getCookie: jest.fn().mockResolvedValue({ expiresAt }),
};
renderHook(() => useCookieAuthRefresh({ apiRef }), {
renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[apiRef, apiMock]]}>{children}</TestApiProvider>
<TestApiProvider
apis={[
[fetchApiRef, fetchApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
{children}
</TestApiProvider>
),
});
expect(apiMock.getCookie).toHaveBeenCalledTimes(1);
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1));
// advance the timers to the expiration date
jest.advanceTimersByTime(tenMinutesInMilliseconds);
// should call the api
await waitFor(() => expect(apiMock.getCookie).toHaveBeenCalledTimes(2));
await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2));
});
});
@@ -15,9 +15,13 @@
*/
import { useEffect, useState, useCallback } from 'react';
import { ApiRef, useApi } from '@backstage/core-plugin-api';
import {
discoveryApiRef,
fetchApiRef,
useApi,
} from '@backstage/core-plugin-api';
import { useAsync, useMountEffect } from '@react-hookz/web';
import { AuthApi } from '../../types';
import { ResponseError } from '@backstage/errors';
type CookieAuthRefreshMessage = MessageEvent<{
action: string;
@@ -28,22 +32,43 @@ type CookieAuthRefreshMessage = MessageEvent<{
/**
* @public
* It receives an `apiRef` as options, and expects that apiRef extends the `AuthApi` interface.
* The options for the {@link useCookieAuthRefresh} hook.
*/
export type CookieAuthRefreshOptions = {
// The plugin ID to used for discovering the API origin
pluginId: string;
// The path to used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
};
/**
* @public
* A hook that will refresh the cookie when it is about to expire.
* @remarks
* This hook expects a `BroadcastChannel` to be available in the global scope.
*/
export function useCookieAuthRefresh<T extends AuthApi>({
apiRef,
}: {
apiRef: ApiRef<T>;
}) {
const api = useApi(apiRef);
export function useCookieAuthRefresh({
pluginId,
path = '/cookie',
}: CookieAuthRefreshOptions) {
const fetchApi = useApi(fetchApiRef);
const discoveryApi = useApi(discoveryApiRef);
const [channel] = useState(
() => new BroadcastChannel(`${apiRef.id}-auth-cookie-channel`),
() => new BroadcastChannel(`${pluginId}-auth-cookie-channel`),
);
const [state, actions] = useAsync(async () => await api.getCookie());
const [state, actions] = useAsync(async () => {
const apiOrigin = await discoveryApi.getBaseUrl(pluginId);
const requestUrl = `${apiOrigin}${path}`;
const response = await fetchApi.fetch(`${requestUrl}`, {
credentials: 'include',
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return await response.json();
});
useMountEffect(actions.execute);
-1
View File
@@ -25,4 +25,3 @@
export * from './components';
export * from './hooks';
export * from './types';
-21
View File
@@ -1,21 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @public
* Defines a minimal inteface for auth apis.
*/
export type AuthApi = { getCookie(): Promise<{ expiresAt: string }> };