From 81793abc0c563bb01de8ea45b3b33cf6c97f0336 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 13:56:35 +0100 Subject: [PATCH 01/13] refactor: create a generic auth cookie provider Signed-off-by: Camila Belo --- plugins/auth-react/.eslintrc.js | 1 + plugins/auth-react/README.md | 5 ++ plugins/auth-react/catalog-info.yaml | 10 +++ plugins/auth-react/package.json | 44 ++++++++++ .../CookieAuthRefreshProvider.tsx | 51 ++++++++++++ .../CookieAuthRefreshProvider/index.ts | 17 ++++ plugins/auth-react/src/components/index.ts | 20 +++++ plugins/auth-react/src/hooks/index.ts | 20 +++++ .../src/hooks/useCookieAuthRefresh/index.ts | 17 ++++ .../useCookieAuthRefresh.tsx | 82 +++++++++++++++++++ plugins/auth-react/src/index.ts | 27 ++++++ plugins/auth-react/src/setupTests.ts | 16 ++++ plugins/auth-react/src/types.ts | 17 ++++ yarn.lock | 20 ++++- 14 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 plugins/auth-react/.eslintrc.js create mode 100644 plugins/auth-react/README.md create mode 100644 plugins/auth-react/catalog-info.yaml create mode 100644 plugins/auth-react/package.json create mode 100644 plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx create mode 100644 plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts create mode 100644 plugins/auth-react/src/components/index.ts create mode 100644 plugins/auth-react/src/hooks/index.ts create mode 100644 plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts create mode 100644 plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx create mode 100644 plugins/auth-react/src/index.ts create mode 100644 plugins/auth-react/src/setupTests.ts create mode 100644 plugins/auth-react/src/types.ts diff --git a/plugins/auth-react/.eslintrc.js b/plugins/auth-react/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-react/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-react/README.md b/plugins/auth-react/README.md new file mode 100644 index 0000000000..0137f95371 --- /dev/null +++ b/plugins/auth-react/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-auth-react + +Welcome to the web library package for the auth plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/auth-react/catalog-info.yaml b/plugins/auth-react/catalog-info.yaml new file mode 100644 index 0000000000..b75aa32c56 --- /dev/null +++ b/plugins/auth-react/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-react + title: '@backstage/plugin-auth-react' + description: Web library for the auth plugin +spec: + lifecycle: experimental + type: backstage-web-library + owner: maintainers diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json new file mode 100644 index 0000000000..089dafd7f6 --- /dev/null +++ b/plugins/auth-react/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/plugin-auth-react", + "description": "Web library for the auth plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "web-library" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@material-ui/core": "^4.9.13", + "@react-hookz/web": "^24.0.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx new file mode 100644 index 0000000000..941717f1e1 --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -0,0 +1,51 @@ +/* + * 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. + */ + +import React, { ReactNode } from 'react'; +import { ErrorPanel } from '@backstage/core-components'; +import { ApiRef, useApp } from '@backstage/core-plugin-api'; +import { Button } from '@material-ui/core'; +import { useCookieAuthRefresh } from '../../hooks'; +import { AuthApi } from '../../types'; + +export function CookieAuthRefreshProvider({ + apiRef, + children, +}: { + apiRef: ApiRef; + children: ReactNode; +}) { + const app = useApp(); + const { Progress } = app.getComponents(); + + const { state, actions } = useCookieAuthRefresh({ apiRef }); + + if (state.status === 'error' && state.error) { + return ( + + + + ); + } + + if (state.status === 'loading') { + return ; + } + + return children; +} diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts b/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts new file mode 100644 index 0000000000..968fa5c4b4 --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { CookieAuthRefreshProvider } from './CookieAuthRefreshProvider'; diff --git a/plugins/auth-react/src/components/index.ts b/plugins/auth-react/src/components/index.ts new file mode 100644 index 0000000000..f561672096 --- /dev/null +++ b/plugins/auth-react/src/components/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +// The index file in ./components/ is typically responsible for selecting +// which components are public API and should be exported from the package. + +export * from './CookieAuthRefreshProvider'; diff --git a/plugins/auth-react/src/hooks/index.ts b/plugins/auth-react/src/hooks/index.ts new file mode 100644 index 0000000000..1257334498 --- /dev/null +++ b/plugins/auth-react/src/hooks/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +// The index file in ./hooks/ is typically responsible for selecting +// which hooks are public API and should be exported from the package. + +export * from './useCookieAuthRefresh'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts b/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts new file mode 100644 index 0000000000..42b60840f8 --- /dev/null +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { useCookieAuthRefresh } from './useCookieAuthRefresh'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx new file mode 100644 index 0000000000..338c662486 --- /dev/null +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -0,0 +1,82 @@ +/* + * 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. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { ApiRef, useApi } from '@backstage/core-plugin-api'; +import { useAsync, useMountEffect } from '@react-hookz/web'; +import { AuthApi } from '../../types'; + +type CookieAuthRefreshMessage = MessageEvent<{ + action: string; + payload: { + expiresAt: string; + }; +}>; + +export function useCookieAuthRefresh({ + apiRef, +}: { + apiRef: ApiRef; +}) { + const api = useApi(apiRef); + + const [channel] = useState( + () => new BroadcastChannel(`${apiRef.id}.auth.cookie.channel`), + ); + + const [state, actions] = useAsync(async () => await api.getCookie()); + + useMountEffect(actions.execute); + + const refresh = useCallback( + (options: { 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 timeout = setTimeout(actions.execute, delay); + return () => clearTimeout(timeout); + }, + [actions], + ); + + useEffect(() => { + if (!state.result) return () => {}; + + channel.postMessage({ + action: 'COOKIE_REFRESHED', + payload: state.result, + }); + + let cancel = refresh(state.result); + + const handleMessage = (event: CookieAuthRefreshMessage): void => { + const { action, payload } = event.data; + if (action === 'COOKIE_REFRESHED') { + cancel(); + cancel = refresh(payload); + } + }; + + channel.addEventListener('message', handleMessage); + + return () => { + cancel(); + channel.removeEventListener('message', handleMessage); + }; + }, [state, refresh, channel]); + + return { state, actions }; +} diff --git a/plugins/auth-react/src/index.ts b/plugins/auth-react/src/index.ts new file mode 100644 index 0000000000..30ac7cfe72 --- /dev/null +++ b/plugins/auth-react/src/index.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +/** + * Web library for the auth plugin. + * + * @packageDocumentation + */ + +// In this package you might for example export components or hooks +// that are useful to other plugins or modules. + +export * from './components'; +export * from './hooks'; diff --git a/plugins/auth-react/src/setupTests.ts b/plugins/auth-react/src/setupTests.ts new file mode 100644 index 0000000000..658016ffdd --- /dev/null +++ b/plugins/auth-react/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +import '@testing-library/jest-dom'; diff --git a/plugins/auth-react/src/types.ts b/plugins/auth-react/src/types.ts new file mode 100644 index 0000000000..b8c74cec8e --- /dev/null +++ b/plugins/auth-react/src/types.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export type AuthApi = { getCookie(): Promise<{ expiresAt: string }> }; diff --git a/yarn.lock b/yarn.lock index 63e46381d6..c438e5f39d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5110,6 +5110,23 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-react@workspace:^, @backstage/plugin-auth-react@workspace:plugins/auth-react": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-react@workspace:plugins/auth-react" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@material-ui/core": ^4.9.13 + "@react-hookz/web": ^24.0.4 + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^14.0.0 + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-azure-devops-backend@workspace:^, @backstage/plugin-azure-devops-backend@workspace:plugins/azure-devops-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-azure-devops-backend@workspace:plugins/azure-devops-backend" @@ -9852,6 +9869,7 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/integration-react": "workspace:^" + "@backstage/plugin-auth-react": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/plugin-search-react": "workspace:^" @@ -15707,7 +15725,7 @@ __metadata: languageName: node linkType: hard -"@react-hookz/web@npm:^24.0.0": +"@react-hookz/web@npm:^24.0.0, @react-hookz/web@npm:^24.0.4": version: 24.0.4 resolution: "@react-hookz/web@npm:24.0.4" dependencies: From a992f863848271255f19b7105ed15ddde2c56c9a Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 13:58:41 +0100 Subject: [PATCH 02/13] refactor: use generic auth cookie provider in techdocs Signed-off-by: Camila Belo --- plugins/techdocs/package.json | 1 + .../TechDocsAuthProvider.tsx | 103 ------------------ .../TechDocsReaderPage/TechDocsReaderPage.tsx | 12 +- 3 files changed, 8 insertions(+), 108 deletions(-) delete mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 533736c3bc..a51eb5f723 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -60,6 +60,7 @@ "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/integration-react": "workspace:^", + "@backstage/plugin-auth-react": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/plugin-search-react": "workspace:^", diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx deleted file mode 100644 index baeb809a6c..0000000000 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx +++ /dev/null @@ -1,103 +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. - */ - -import React, { ReactNode, useEffect, useState, useCallback } from 'react'; -import { ErrorPanel } from '@backstage/core-components'; -import { techdocsApiRef } from '@backstage/plugin-techdocs-react'; -import { useApi, useApp } from '@backstage/core-plugin-api'; -import useAsyncRetry from 'react-use/lib/useAsyncRetry'; -import Button from '@material-ui/core/Button'; - -type TechDocsRefreshCookieMessage = MessageEvent<{ - action: string; - payload: { - expiresAt: string; - }; -}>; - -function useTechDocsCookie() { - const techdocsApi = useApi(techdocsApiRef); - - const { retry, ...state } = useAsyncRetry(async () => { - return await techdocsApi.getCookie(); - }, [techdocsApi]); - - const refresh = useCallback( - (expiresAt: string) => { - // Randomize the refreshing margin to avoid all tabs refreshing at the same time - const refreshingMargin = (1 + 3 * Math.random()) * 60000; - const delay = Date.parse(expiresAt) - Date.now() - refreshingMargin; - const timeout = setTimeout(retry, delay); - return () => clearTimeout(timeout); - }, - [retry], - ); - - return { ...state, retry, refresh }; -} - -export function TechDocsAuthProvider({ children }: { children: ReactNode }) { - const app = useApp(); - const { Progress } = app.getComponents(); - - const [channel] = useState( - () => new BroadcastChannel('techdocs-cookie-refresh'), - ); - - const { loading, error, value, retry, refresh } = useTechDocsCookie(); - - useEffect(() => { - if (!value) return () => {}; - - channel.postMessage({ - action: 'TECHDOCS_COOKIE_REFRESHED', - payload: value, - }); - - let cancel = refresh(value.expiresAt); - - const handleMessage = (event: TechDocsRefreshCookieMessage): void => { - const { action, payload } = event.data; - if (action === 'TECHDOCS_COOKIE_REFRESHED') { - cancel(); - cancel = refresh(payload.expiresAt); - } - }; - - channel.addEventListener('message', handleMessage); - - return () => { - cancel(); - channel.removeEventListener('message', handleMessage); - }; - }, [value, refresh, channel]); - - if (error) { - return ( - - - - ); - } - - if (loading) { - return ; - } - - return children; -} diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index d7f3843ebb..2a366b7da4 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -23,6 +23,7 @@ import { TECHDOCS_ADDONS_WRAPPER_KEY, TECHDOCS_ADDONS_KEY, TechDocsReaderPageProvider, + techdocsApiRef, } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPageRenderFunction } from '../../../types'; @@ -35,7 +36,8 @@ import { getComponentData, useRouteRefParams, } from '@backstage/core-plugin-api'; -import { TechDocsAuthProvider } from './TechDocsAuthProvider'; + +import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -178,17 +180,17 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { // As explained above, "page" is configuration 4 and is 1 return ( - + {(page as JSX.Element) || } - + ); } // As explained above, a render function is configuration 3 and React element is 2 return ( - + {({ metadata, entityMetadata, onReady }) => (
@@ -205,6 +207,6 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
)}
-
+ ); }; From e0690351589fc2584f3761ef2e8915c668a74821 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Mar 2024 10:57:41 +0100 Subject: [PATCH 03/13] test: cover use cookie auth hook Signed-off-by: Camila Belo --- .../useCookieAuthRefresh.test.tsx | 239 ++++++++++++++++++ .../useCookieAuthRefresh.tsx | 2 +- plugins/auth-react/src/setupTests.ts | 25 ++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx new file mode 100644 index 0000000000..7a3efd5fd0 --- /dev/null +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -0,0 +1,239 @@ +/* + * 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. + */ + +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { createApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; +import { useCookieAuthRefresh } from './useCookieAuthRefresh'; +import { AuthApi } from '../../types'; + +describe('useCookieAuthRefresh', () => { + const now = 1710316886171; + const tenMinutesInMilliseconds = 10 * 60 * 1000; + const tenMinutesFromNowInMilliseconds = now + tenMinutesInMilliseconds; + const expiresAt = new Date(tenMinutesFromNowInMilliseconds).toISOString(); + + type Listener = (event: { data: any }) => void; + + let listeners: Listener[]; + let channelMock: any; + + beforeEach(() => { + jest.useFakeTimers({ now }); + listeners = []; + channelMock = { + postMessage: jest.fn((message: any) => { + listeners.forEach(listener => listener({ data: message })); + }), + addEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners.push(listener); + } + }), + removeEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners = listeners.filter(l => l !== listener); + } + }), + }; + global.BroadcastChannel = jest.fn().mockImplementation(() => channelMock); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return a loading status when the refresh is in progress', () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const apiMock = { + getCookie: jest.fn(), + }; + + const { result } = renderHook(() => useCookieAuthRefresh({ apiRef }), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current.state.status).toBe('loading'); + }); + + it('should return an error status when the refresh has failed', async () => { + const apiRef = createApiRef({ 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 }) => ( + {children} + ), + }); + + await waitFor(() => expect(result.current.state.status).toBe('error')); + + expect(result.current.state.error).toStrictEqual(error); + }); + + it('should call the api to get the cookie and use it', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const apiMock = { + getCookie: jest.fn().mockResolvedValue({ expiresAt }), + }; + + const { result } = renderHook(() => useCookieAuthRefresh({ apiRef }), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(apiMock.getCookie).toHaveBeenCalled(); + + await waitFor(() => + expect(result.current.state.result).toMatchObject({ expiresAt }), + ); + }); + + it('should send a message to other tabs when the cookie is refreshed', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const apiMock = { + getCookie: jest.fn().mockResolvedValue({ expiresAt }), + }; + + renderHook(() => useCookieAuthRefresh({ apiRef }), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(global.BroadcastChannel).toHaveBeenCalledWith( + 'auth-test-auth-cookie-channel', + ); + + await waitFor(() => + expect(channelMock.postMessage).toHaveBeenCalledTimes(1), + ); + + // posting the message to other tabs when the cookie is requested in the first time + await waitFor(() => + expect(channelMock.postMessage).toHaveBeenCalledWith({ + action: 'COOKIE_REFRESHED', + payload: { expiresAt }, + }), + ); + }); + + it('should cancel the refresh when a message is received from another tab', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const apiMock = { + getCookie: jest.fn().mockResolvedValue({ expiresAt }), + }; + + renderHook(() => useCookieAuthRefresh({ apiRef }), { + wrapper: ({ children }) => ( + {children} + ), + }); + + await waitFor(() => + expect(channelMock.addEventListener).toHaveBeenCalledTimes(1), + ); + + const twentyMinutesFromNowInMilliseconds = + now + 2 * tenMinutesInMilliseconds; + + // simulating other tab refreshing the cookie + channelMock.postMessage({ + action: 'COOKIE_REFRESHED', + payload: { + expiresAt: new Date(twentyMinutesFromNowInMilliseconds).toISOString(), + }, + }); + + // advance the timers in 10 minutes to match the old expires at + jest.advanceTimersByTime(tenMinutesInMilliseconds); + + // should not call the api + expect(apiMock.getCookie).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)); + }); + + it('should cancel the refresh when the component is unmounted', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const apiMock = { + getCookie: jest.fn().mockResolvedValue({ expiresAt }), + }; + + const { result, unmount } = renderHook( + () => useCookieAuthRefresh({ apiRef }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(apiMock.getCookie).toHaveBeenCalledTimes(1); + + await waitFor(() => + expect(result.current.state.result).toMatchObject({ expiresAt }), + ); + + unmount(); + + expect(channelMock.removeEventListener).toHaveBeenCalledTimes(1); + expect(channelMock.removeEventListener).toHaveBeenCalledWith( + 'message', + expect.any(Function), + ); + + // advance the timers to ensure that the refresh is not called + jest.advanceTimersByTime(tenMinutesInMilliseconds); + + // should not call the api after unmount + await waitFor(() => expect(apiMock.getCookie).not.toHaveBeenCalledTimes(2)); + }); + + it('should refresh the cookie when it is about to expire', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const apiMock = { + getCookie: jest.fn().mockResolvedValue({ expiresAt }), + }; + + renderHook(() => useCookieAuthRefresh({ apiRef }), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(apiMock.getCookie).toHaveBeenCalledTimes(1); + + // advance the timers to the expiration date + jest.advanceTimersByTime(tenMinutesInMilliseconds); + + // should call the api + await waitFor(() => expect(apiMock.getCookie).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index 338c662486..7888fa7e81 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -34,7 +34,7 @@ export function useCookieAuthRefresh({ const api = useApi(apiRef); const [channel] = useState( - () => new BroadcastChannel(`${apiRef.id}.auth.cookie.channel`), + () => new BroadcastChannel(`${apiRef.id}-auth-cookie-channel`), ); const [state, actions] = useAsync(async () => await api.getCookie()); diff --git a/plugins/auth-react/src/setupTests.ts b/plugins/auth-react/src/setupTests.ts index 658016ffdd..0ab6a810cf 100644 --- a/plugins/auth-react/src/setupTests.ts +++ b/plugins/auth-react/src/setupTests.ts @@ -14,3 +14,28 @@ * limitations under the License. */ import '@testing-library/jest-dom'; + +type Listener = (event: { data: any }) => void; + +global.BroadcastChannel = jest + .fn() + .mockImplementation((_channelName: string) => { + let listeners: Listener[] = []; + return { + postMessage: jest.fn((message: any) => { + // Simulate message event for all listeners + listeners.forEach(listener => listener({ data: message })); + }), + addEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners.push(listener); + } + }), + removeEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners = listeners.filter(l => l !== listener); + } + }), + close: jest.fn(), + }; + }); From 756ddb637f6c05a2678b435561dc413adcedeb36 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Mar 2024 12:20:47 +0100 Subject: [PATCH 04/13] test: cover use cookie auth provider Signed-off-by: Camila Belo --- plugins/auth-react/package.json | 37 +++--- .../CookieAuthRefreshProvider.test.tsx | 124 ++++++++++++++++++ yarn.lock | 1 + 3 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index 089dafd7f6..fbe44d91d4 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -1,27 +1,30 @@ { "name": "@backstage/plugin-auth-react", - "description": "Web library for the auth plugin", "version": "0.0.0", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", + "description": "Web library for the auth plugin", + "backstage": { + "role": "web-library" + }, "publishConfig": { "access": "public", "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, - "backstage": { - "role": "web-library" - }, + "license": "Apache-2.0", "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], "scripts": { - "start": "backstage-cli package start", "build": "backstage-cli package build", - "lint": "backstage-cli package lint", - "test": "backstage-cli package test", "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack" + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/core-components": "workspace:^", @@ -29,16 +32,14 @@ "@material-ui/core": "^4.9.13", "@react-hookz/web": "^24.0.4" }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" - }, "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0" + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.0.0" }, - "files": [ - "dist" - ] + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + } } diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx new file mode 100644 index 0000000000..6684fa7856 --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx @@ -0,0 +1,124 @@ +/* + * 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. + */ + +import React from 'react'; +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'; + +describe('CookieAuthRefreshProvider', () => { + function getExpiresAtInFuture() { + const tenMinutesInMilliseconds = 10 * 60 * 1000; + return new Date(Date.now() + tenMinutesInMilliseconds).toISOString(); + } + + global.BroadcastChannel = jest.fn().mockImplementation(() => ({ + postMessage: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + it('should render a progress bar', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const apiMock = { + getCookie: jest.fn().mockReturnValue(new Promise(() => {})), + }; + + await renderInTestApp( + + +
Test Content
+
+
, + ); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('should render a error panel', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const error = new Error('Failed to get cookie'); + const apiMock = { + getCookie: jest.fn().mockRejectedValue(error), + }; + + await renderInTestApp( + + +
Test Content
+
+
, + ); + + expect(screen.getByText(error.message)).toBeInTheDocument(); + }); + + it('should call the api again when retry is clicked', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const error = new Error('Failed to get cookie'); + const apiMock = { + getCookie: jest.fn().mockRejectedValueOnce(error).mockResolvedValue({ + expiresAt: getExpiresAtInFuture(), + }), + }; + + await renderInTestApp( + + +
Test Content
+
+
, + ); + + expect(apiMock.getCookie).toHaveBeenCalledTimes(1); + + expect(screen.getByText(error.message)).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Retry')); + + expect(apiMock.getCookie).toHaveBeenCalledTimes(2); + + await waitFor(() => + expect(screen.getByText('Test Content')).toBeInTheDocument(), + ); + }); + + it('should render the children', async () => { + const apiRef = createApiRef({ id: 'auth-test' }); + const apiMock = { + getCookie: jest.fn().mockResolvedValue({ + expiresAt: { + expiresAt: getExpiresAtInFuture(), + }, + }), + }; + + await renderInTestApp( + + +
Test Content
+
+
, + ); + + await waitFor(() => + expect(screen.getByText('Test Content')).toBeInTheDocument(), + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index c438e5f39d..3a8010a02c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5122,6 +5122,7 @@ __metadata: "@react-hookz/web": ^24.0.4 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 + "@testing-library/user-event": ^14.0.0 peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 languageName: unknown From a7db1e651dbb5910ac618362a5c339a372b700c4 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Mar 2024 12:36:23 +0100 Subject: [PATCH 05/13] docs: update api reports Signed-off-by: Camila Belo --- plugins/auth-react/api-report.md | 54 +++++++++++++++++++ .../CookieAuthRefreshProvider.tsx | 5 ++ .../useCookieAuthRefresh.tsx | 6 +++ plugins/auth-react/src/index.ts | 1 + plugins/auth-react/src/types.ts | 4 ++ 5 files changed, 70 insertions(+) create mode 100644 plugins/auth-react/api-report.md diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md new file mode 100644 index 0000000000..f329673996 --- /dev/null +++ b/plugins/auth-react/api-report.md @@ -0,0 +1,54 @@ +## API Report File for "@backstage/plugin-auth-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core-plugin-api'; +import { AsyncState } from '@react-hookz/web'; +import { default as React_2 } from 'react'; +import { ReactNode } from 'react'; +import { UseAsyncActions } from '@react-hookz/web'; + +// @public +export type AuthApi = { + getCookie(): Promise<{ + expiresAt: string; + }>; +}; + +// @public +export function CookieAuthRefreshProvider({ + apiRef, + children, +}: { + apiRef: ApiRef; + children: ReactNode; +}): + | string + | number + | boolean + | Iterable + | React_2.JSX.Element + | null + | undefined; + +// @public +export function useCookieAuthRefresh({ + apiRef, +}: { + apiRef: ApiRef; +}): { + state: AsyncState< + | { + expiresAt: string; + } + | undefined + >; + actions: UseAsyncActions< + { + expiresAt: string; + }, + [] + >; +}; +``` diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 941717f1e1..86fa5a3a6f 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -21,6 +21,11 @@ import { Button } from '@material-ui/core'; import { useCookieAuthRefresh } from '../../hooks'; import { AuthApi } from '../../types'; +/** + * @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({ apiRef, children, diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index 7888fa7e81..5c3272d056 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -26,6 +26,12 @@ type CookieAuthRefreshMessage = MessageEvent<{ }; }>; +/** + * @public + * It receives an `apiRef` as options, and expects that apiRef extends the `AuthApi` interface. + * @remarks + * This hook expects a `BroadcastChannel` to be available in the global scope. + */ export function useCookieAuthRefresh({ apiRef, }: { diff --git a/plugins/auth-react/src/index.ts b/plugins/auth-react/src/index.ts index 30ac7cfe72..2569454bf3 100644 --- a/plugins/auth-react/src/index.ts +++ b/plugins/auth-react/src/index.ts @@ -25,3 +25,4 @@ export * from './components'; export * from './hooks'; +export * from './types'; diff --git a/plugins/auth-react/src/types.ts b/plugins/auth-react/src/types.ts index b8c74cec8e..dd5eadfdee 100644 --- a/plugins/auth-react/src/types.ts +++ b/plugins/auth-react/src/types.ts @@ -14,4 +14,8 @@ * limitations under the License. */ +/** + * @public + * Defines a minimal inteface for auth apis. + */ export type AuthApi = { getCookie(): Promise<{ expiresAt: string }> }; From 62bcaf8e4a599a58410ad0d1f496646a2020c6a8 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Mar 2024 12:39:08 +0100 Subject: [PATCH 06/13] docs: add changeset files Signed-off-by: Camila Belo --- .changeset/fair-socks-peel.md | 5 +++++ .changeset/serious-steaks-promise.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/fair-socks-peel.md create mode 100644 .changeset/serious-steaks-promise.md diff --git a/.changeset/fair-socks-peel.md b/.changeset/fair-socks-peel.md new file mode 100644 index 0000000000..88aeb6517c --- /dev/null +++ b/.changeset/fair-socks-peel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-react': patch +--- + +Create a generic React component for refreshing user cookie. diff --git a/.changeset/serious-steaks-promise.md b/.changeset/serious-steaks-promise.md new file mode 100644 index 0000000000..cd37f4ef3e --- /dev/null +++ b/.changeset/serious-steaks-promise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Use the new generic refresh user cookie provider. From 40817356a86adfa428f3ed40cfb72bf2a66b9fbe Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Mar 2024 13:20:30 +0100 Subject: [PATCH 07/13] fix: missing repo in package.json Signed-off-by: Camila Belo --- plugins/auth-react/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index fbe44d91d4..2bfb842800 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -10,6 +10,11 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth-react" + }, "license": "Apache-2.0", "sideEffects": false, "main": "src/index.ts", From b806745876268a5cb0f27c33c98b9901c2d76241 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 13 Mar 2024 14:14:34 +0100 Subject: [PATCH 08/13] refactor: apply review suggestions Signed-off-by: Camila Belo --- .changeset/serious-steaks-promise.md | 1 + plugins/auth-react/api-report.md | 45 ++-- plugins/auth-react/package.json | 4 +- .../CookieAuthRefreshProvider.test.tsx | 93 ++++++--- .../CookieAuthRefreshProvider.tsx | 26 ++- .../CookieAuthRefreshProvider/index.ts | 5 +- .../src/hooks/useCookieAuthRefresh/index.ts | 5 +- .../useCookieAuthRefresh.test.tsx | 192 +++++++++++------- .../useCookieAuthRefresh.tsx | 47 ++++- plugins/auth-react/src/index.ts | 1 - plugins/auth-react/src/types.ts | 21 -- .../src/test-utils.tsx | 24 ++- .../TechDocsReaderPage.test.tsx | 26 ++- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 5 +- yarn.lock | 2 + 15 files changed, 315 insertions(+), 182 deletions(-) delete mode 100644 plugins/auth-react/src/types.ts diff --git a/.changeset/serious-steaks-promise.md b/.changeset/serious-steaks-promise.md index cd37f4ef3e..55fee86f60 100644 --- a/.changeset/serious-steaks-promise.md +++ b/.changeset/serious-steaks-promise.md @@ -1,5 +1,6 @@ --- '@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs-addons-test-utils': patch --- Use the new generic refresh user cookie provider. diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index f329673996..0e8a7dc8b7 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -3,27 +3,22 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { ApiRef } from '@backstage/core-plugin-api'; import { AsyncState } from '@react-hookz/web'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; import { UseAsyncActions } from '@react-hookz/web'; // @public -export type AuthApi = { - getCookie(): Promise<{ - expiresAt: string; - }>; +export type CookieAuthRefreshOptions = { + pluginId: string; + path?: string; }; // @public -export function CookieAuthRefreshProvider({ - apiRef, +export function CookieAuthRefreshProvider({ children, -}: { - apiRef: ApiRef; - children: ReactNode; -}): + ...rest +}: CookieAuthRefreshProviderProps): | string | number | boolean @@ -33,22 +28,16 @@ export function CookieAuthRefreshProvider({ | undefined; // @public -export function useCookieAuthRefresh({ - apiRef, -}: { - apiRef: ApiRef; -}): { - state: AsyncState< - | { - expiresAt: string; - } - | undefined - >; - actions: UseAsyncActions< - { - expiresAt: string; - }, - [] - >; +export type CookieAuthRefreshProviderProps = CookieAuthRefreshOptions & { + children: ReactNode; +}; + +// @public +export function useCookieAuthRefresh({ + pluginId, + path, +}: CookieAuthRefreshOptions): { + state: AsyncState; + actions: UseAsyncActions; }; ``` diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index 2bfb842800..7bc248be67 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -34,8 +34,10 @@ "dependencies": { "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@material-ui/core": "^4.9.13", - "@react-hookz/web": "^24.0.4" + "@react-hookz/web": "^24.0.4", + "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx index 6684fa7856..e8e5eea24d 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx @@ -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({ id: 'auth-test' }); - const apiMock = { - getCookie: jest.fn().mockReturnValue(new Promise(() => {})), + const fetchApiMock = { + fetch: jest.fn().mockReturnValue(new Promise(() => {})), }; await renderInTestApp( - - + +
Test Content
, @@ -51,16 +60,20 @@ describe('CookieAuthRefreshProvider', () => { expect(screen.getByTestId('progress')).toBeInTheDocument(); }); - it('should render a error panel', async () => { - const apiRef = createApiRef({ 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( - - + +
Test Content
, @@ -70,29 +83,46 @@ describe('CookieAuthRefreshProvider', () => { }); it('should call the api again when retry is clicked', async () => { - const apiRef = createApiRef({ 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( - - + +
Test Content
, ); - 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({ 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( - - + +
Test Content
, diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 86fa5a3a6f..4265b25cf0 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -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({ - apiRef, +export function CookieAuthRefreshProvider({ children, -}: { - apiRef: ApiRef; - 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 ( diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts b/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts index 968fa5c4b4..2bb172d850 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts @@ -14,4 +14,7 @@ * limitations under the License. */ -export { CookieAuthRefreshProvider } from './CookieAuthRefreshProvider'; +export { + CookieAuthRefreshProvider, + type CookieAuthRefreshProviderProps, +} from './CookieAuthRefreshProvider'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts b/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts index 42b60840f8..43b1882959 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts @@ -14,4 +14,7 @@ * limitations under the License. */ -export { useCookieAuthRefresh } from './useCookieAuthRefresh'; +export { + useCookieAuthRefresh, + type CookieAuthRefreshOptions, +} from './useCookieAuthRefresh'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx index 7a3efd5fd0..42fe6f4fca 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -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({ id: 'auth-test' }); - const apiMock = { - getCookie: jest.fn(), - }; - - const { result } = renderHook(() => useCookieAuthRefresh({ apiRef }), { - wrapper: ({ children }) => ( - {children} - ), - }); + const { result } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); expect(result.current.state.status).toBe('loading'); }); it('should return an error status when the refresh has failed', async () => { - const apiRef = createApiRef({ 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 }) => ( - {children} - ), - }); + const { result } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); 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({ id: 'auth-test' }); - const apiMock = { - getCookie: jest.fn().mockResolvedValue({ expiresAt }), - }; - - const { result } = renderHook(() => useCookieAuthRefresh({ apiRef }), { - wrapper: ({ children }) => ( - {children} - ), - }); - - expect(apiMock.getCookie).toHaveBeenCalled(); + const { result } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); 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({ id: 'auth-test' }); - const apiMock = { - getCookie: jest.fn().mockResolvedValue({ expiresAt }), - }; - - renderHook(() => useCookieAuthRefresh({ apiRef }), { + renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), { wrapper: ({ children }) => ( - {children} + + {children} + ), }); 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({ id: 'auth-test' }); - const apiMock = { - getCookie: jest.fn().mockResolvedValue({ expiresAt }), - }; - - renderHook(() => useCookieAuthRefresh({ apiRef }), { + renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), { wrapper: ({ children }) => ( - {children} + + {children} + ), }); @@ -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({ id: 'auth-test' }); - const apiMock = { - getCookie: jest.fn().mockResolvedValue({ expiresAt }), - }; - const { result, unmount } = renderHook( - () => useCookieAuthRefresh({ apiRef }), + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), { wrapper: ({ children }) => ( - + {children} ), }, ); - 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({ id: 'auth-test' }); - const apiMock = { - getCookie: jest.fn().mockResolvedValue({ expiresAt }), - }; - - renderHook(() => useCookieAuthRefresh({ apiRef }), { + renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), { wrapper: ({ children }) => ( - {children} + + {children} + ), }); - 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)); }); }); diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index 5c3272d056..2d495d54c4 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -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({ - apiRef, -}: { - apiRef: ApiRef; -}) { - 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); diff --git a/plugins/auth-react/src/index.ts b/plugins/auth-react/src/index.ts index 2569454bf3..30ac7cfe72 100644 --- a/plugins/auth-react/src/index.ts +++ b/plugins/auth-react/src/index.ts @@ -25,4 +25,3 @@ export * from './components'; export * from './hooks'; -export * from './types'; diff --git a/plugins/auth-react/src/types.ts b/plugins/auth-react/src/types.ts deleted file mode 100644 index dd5eadfdee..0000000000 --- a/plugins/auth-react/src/types.ts +++ /dev/null @@ -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 }> }; diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index f26dfea6fd..51a4650f99 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -24,7 +24,11 @@ import { act, render } from '@testing-library/react'; import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { FlatRoutes } from '@backstage/core-app-api'; -import { ApiRef } from '@backstage/core-plugin-api'; +import { + ApiRef, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; import { TechDocsAddons, @@ -69,6 +73,22 @@ const scmIntegrationsApi = { fromConfig: jest.fn().mockReturnValue({}), }; +const discoveryApi = { + getBaseUrl: jest + .fn() + .mockResolvedValue('https://backstage.example.com/api/techdocs'), +}; + +const fetchApi = { + fetch: jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + // Expires in 10 minutes + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + }), + }), +}; + /** @ignore */ type TechDocsAddonTesterTestApiPair = TApi extends infer TImpl ? readonly [ApiRef, Partial] @@ -204,6 +224,8 @@ export class TechDocsAddonTester { */ build() { const apis: TechdocsAddonTesterApis = [ + [fetchApiRef, fetchApi], + [discoveryApiRef, discoveryApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], [searchApiRef, searchApi], diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index 605decae12..8295ea0688 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -34,7 +34,11 @@ import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; import { FlatRoutes } from '@backstage/core-app-api'; import { Page } from '@backstage/core-components'; -import { configApiRef } from '@backstage/core-plugin-api'; +import { + configApiRef, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; const mockEntityMetadata = { locationMetadata: { @@ -76,6 +80,22 @@ const techdocsStorageApiMock: jest.Mocked = { syncEntityDocs: jest.fn(), }; +const discoveryApiMock = { + getBaseUrl: jest + .fn() + .mockResolvedValue('https://localhost:7000/api/techdocs'), +}; + +const fetchApiMock = { + fetch: jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + // Expires in 10 minutes + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + }), + }), +}; + const PageMock = () => { const { namespace, kind, name } = useParams(); return <>{`PageMock: ${namespace}#${kind}#${name}`}; @@ -96,6 +116,8 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => { return ( ', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); beforeEach(() => { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 2a366b7da4..b1b9ecb13b 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -23,7 +23,6 @@ import { TECHDOCS_ADDONS_WRAPPER_KEY, TECHDOCS_ADDONS_KEY, TechDocsReaderPageProvider, - techdocsApiRef, } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPageRenderFunction } from '../../../types'; @@ -180,7 +179,7 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { // As explained above, "page" is configuration 4 and is 1 return ( - + {(page as JSX.Element) || } @@ -190,7 +189,7 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { // As explained above, a render function is configuration 3 and React element is 2 return ( - + {({ metadata, entityMetadata, onReady }) => (
diff --git a/yarn.lock b/yarn.lock index 3a8010a02c..07c751cc3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5117,12 +5117,14 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/test-utils": "workspace:^" "@material-ui/core": ^4.9.13 "@react-hookz/web": ^24.0.4 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 "@testing-library/user-event": ^14.0.0 + "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0 peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 languageName: unknown From aaab35ee171447b192be4879209aad70cff949f8 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 14 Mar 2024 08:42:35 +0100 Subject: [PATCH 09/13] refactor: apply second round of suggestions Signed-off-by: Camila Belo --- plugins/auth-react/api-report.md | 53 ++++----- plugins/auth-react/package.json | 2 +- .../CookieAuthRefreshProvider.test.tsx | 29 +++-- .../CookieAuthRefreshProvider.tsx | 37 +++--- .../src/hooks/useCookieAuthRefresh/index.ts | 5 +- .../useCookieAuthRefresh.test.tsx | 109 +++++------------- .../useCookieAuthRefresh.tsx | 76 +++++------- plugins/auth-react/src/setupTests.ts | 25 ---- .../src/test-utils.tsx | 10 +- .../TechDocsReaderPage.test.tsx | 28 +---- plugins/techdocs/src/setupTests.ts | 25 ---- yarn.lock | 4 +- 12 files changed, 145 insertions(+), 258 deletions(-) diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index 0e8a7dc8b7..9c2cf4622d 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -3,41 +3,38 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AsyncState } from '@react-hookz/web'; -import { default as React_2 } from 'react'; import { ReactNode } from 'react'; -import { UseAsyncActions } from '@react-hookz/web'; // @public -export type CookieAuthRefreshOptions = { +export function CookieAuthRefreshProvider( + props: CookieAuthRefreshProviderProps, +): JSX.Element; + +// @public +export type CookieAuthRefreshProviderProps = { pluginId: string; - path?: string; -}; - -// @public -export function CookieAuthRefreshProvider({ - children, - ...rest -}: CookieAuthRefreshProviderProps): - | string - | number - | boolean - | Iterable - | React_2.JSX.Element - | null - | undefined; - -// @public -export type CookieAuthRefreshProviderProps = CookieAuthRefreshOptions & { + options?: { + path?: string; + }; children: ReactNode; }; // @public -export function useCookieAuthRefresh({ - pluginId, - path, -}: CookieAuthRefreshOptions): { - state: AsyncState; - actions: UseAsyncActions; +export function useCookieAuthRefresh(params: { + pluginId: string; + options?: { + path?: string; + }; +}): { + loading: boolean; + error: Error | undefined; + value: + | { + expiresAt: string; + } + | undefined; + retry: (...args: unknown[]) => Promise<{ + expiresAt: string; + }>; }; ``` diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index 7bc248be67..875cb2f12f 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -36,7 +36,7 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@material-ui/core": "^4.9.13", - "@react-hookz/web": "^24.0.4", + "@react-hookz/web": "^24.0.0", "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0" }, "devDependencies": { diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx index e8e5eea24d..786136261c 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx @@ -18,10 +18,19 @@ import React from 'react'; 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 { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { + MockStorageApi, + TestApiProvider, + renderInTestApp, +} from '@backstage/test-utils'; +import { + discoveryApiRef, + fetchApiRef, + storageApiRef, +} from '@backstage/core-plugin-api'; describe('CookieAuthRefreshProvider', () => { + const storageApiMock = MockStorageApi.create(); const discoveryApiMock = { getBaseUrl: jest .fn() @@ -33,12 +42,6 @@ describe('CookieAuthRefreshProvider', () => { return new Date(Date.now() + tenMinutesInMilliseconds).toISOString(); } - global.BroadcastChannel = jest.fn().mockImplementation(() => ({ - postMessage: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - })); - it('should render a progress bar', async () => { const fetchApiMock = { fetch: jest.fn().mockReturnValue(new Promise(() => {})), @@ -48,6 +51,7 @@ describe('CookieAuthRefreshProvider', () => { @@ -57,7 +61,9 @@ describe('CookieAuthRefreshProvider', () => { , ); - expect(screen.getByTestId('progress')).toBeInTheDocument(); + expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); + + expect(screen.getByTestId('progress')).toBeVisible(); }); it('should render an error panel', async () => { @@ -70,6 +76,7 @@ describe('CookieAuthRefreshProvider', () => { @@ -100,6 +107,7 @@ describe('CookieAuthRefreshProvider', () => { @@ -118,6 +126,8 @@ describe('CookieAuthRefreshProvider', () => { expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); + expect(screen.getByText(error.message)).toBeInTheDocument(); await userEvent.click(screen.getByText('Retry')); @@ -143,6 +153,7 @@ describe('CookieAuthRefreshProvider', () => { diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 4265b25cf0..2b553f33e8 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -18,13 +18,20 @@ import React, { ReactNode } from 'react'; import { ErrorPanel } from '@backstage/core-components'; import { useApp } from '@backstage/core-plugin-api'; import { Button } from '@material-ui/core'; -import { useCookieAuthRefresh, CookieAuthRefreshOptions } from '../../hooks'; +import { useCookieAuthRefresh } from '../../hooks'; /** * @public * Props for the {@link CookieAuthRefreshProvider} component. */ -export type CookieAuthRefreshProviderProps = CookieAuthRefreshOptions & { +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 children to render when the refresh is successful children: ReactNode; }; @@ -33,28 +40,28 @@ export type CookieAuthRefreshProviderProps = CookieAuthRefreshOptions & { * @public * A provider that will refresh the cookie when it is about to expire. */ -export function CookieAuthRefreshProvider({ - children, - ...rest -}: CookieAuthRefreshProviderProps) { +export function CookieAuthRefreshProvider( + props: CookieAuthRefreshProviderProps, +): JSX.Element { + const { children, ...params } = props; const app = useApp(); const { Progress } = app.getComponents(); - const { state, actions } = useCookieAuthRefresh(rest); + const { loading, error, retry } = useCookieAuthRefresh(params); - if (state.status === 'error' && state.error) { + if (loading) { + return ; + } + + if (error) { return ( - - ); } - if (state.status === 'loading') { - return ; - } - - return children; + return <>{children}; } diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts b/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts index 43b1882959..42b60840f8 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts @@ -14,7 +14,4 @@ * limitations under the License. */ -export { - useCookieAuthRefresh, - type CookieAuthRefreshOptions, -} from './useCookieAuthRefresh'; +export { useCookieAuthRefresh } from './useCookieAuthRefresh'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx index 42fe6f4fca..eee3659c71 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -16,8 +16,12 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react'; -import { fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api'; -import { TestApiProvider } from '@backstage/test-utils'; +import { + fetchApiRef, + discoveryApiRef, + storageApiRef, +} from '@backstage/core-plugin-api'; +import { MockStorageApi, TestApiProvider } from '@backstage/test-utils'; import { useCookieAuthRefresh } from './useCookieAuthRefresh'; describe('useCookieAuthRefresh', () => { @@ -39,31 +43,11 @@ describe('useCookieAuthRefresh', () => { }), }; - type Listener = (event: { data: any }) => void; - - let listeners: Listener[]; - let channelMock: any; + const storageApiMock = MockStorageApi.create(); beforeEach(() => { jest.useFakeTimers({ now }); jest.clearAllMocks(); - listeners = []; - channelMock = { - postMessage: jest.fn((message: any) => { - listeners.forEach(listener => listener({ data: message })); - }), - addEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners.push(listener); - } - }), - removeEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners = listeners.filter(l => l !== listener); - } - }), - }; - global.BroadcastChannel = jest.fn().mockImplementation(() => channelMock); }); afterEach(() => { @@ -83,6 +67,7 @@ describe('useCookieAuthRefresh', () => { fetch: jest.fn(), }, ], + [storageApiRef, storageApiMock], [discoveryApiRef, discoveryApiMock], ]} > @@ -92,7 +77,7 @@ describe('useCookieAuthRefresh', () => { }, ); - expect(result.current.state.status).toBe('loading'); + expect(result.current.loading).toBeTruthy(); }); it('should return an error status when the refresh has failed', async () => { @@ -110,6 +95,7 @@ describe('useCookieAuthRefresh', () => { fetch: jest.fn().mockRejectedValue(error), }, ], + [storageApiRef, storageApiMock], [discoveryApiRef, discoveryApiMock], ]} > @@ -119,9 +105,7 @@ describe('useCookieAuthRefresh', () => { }, ); - await waitFor(() => expect(result.current.state.status).toBe('error')); - - expect(result.current.state.error).toStrictEqual(error); + await waitFor(() => expect(result.current.error).toStrictEqual(error)); }); it('should call the api to get the cookie and use it', async () => { @@ -132,6 +116,7 @@ describe('useCookieAuthRefresh', () => { @@ -148,46 +133,18 @@ describe('useCookieAuthRefresh', () => { ), ); - expect(result.current.state.result).toMatchObject({ expiresAt }); - }); - - it('should send a message to other tabs when the cookie is refreshed', async () => { - renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(global.BroadcastChannel).toHaveBeenCalledWith( - 'techdocs-auth-cookie-channel', - ); - - await waitFor(() => - expect(channelMock.postMessage).toHaveBeenCalledTimes(1), - ); - - // posting the message to other tabs when the cookie is requested in the first time - await waitFor(() => - expect(channelMock.postMessage).toHaveBeenCalledWith({ - action: 'COOKIE_REFRESHED', - payload: { expiresAt }, - }), - ); + expect(result.current.value).toMatchObject({ expiresAt }); }); it('should cancel the refresh when a message is received from another tab', async () => { - renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), { + const pluginId = 'techdocs'; + + renderHook(() => useCookieAuthRefresh({ pluginId }), { wrapper: ({ children }) => ( @@ -196,29 +153,25 @@ describe('useCookieAuthRefresh', () => { ), }); - await waitFor(() => - expect(channelMock.addEventListener).toHaveBeenCalledTimes(1), - ); - const twentyMinutesFromNowInMilliseconds = now + 2 * tenMinutesInMilliseconds; // simulating other tab refreshing the cookie - channelMock.postMessage({ - action: 'COOKIE_REFRESHED', - payload: { - expiresAt: new Date(twentyMinutesFromNowInMilliseconds).toISOString(), - }, - }); + storageApiMock + .forBucket(`${pluginId}-auth-cookie-storage`) + .set( + 'expiresAt', + new Date(twentyMinutesFromNowInMilliseconds).toISOString(), + ); // advance the timers in 10 minutes to match the old expires at jest.advanceTimersByTime(tenMinutesInMilliseconds); // should not call the api - expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1); + await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1)); // advance the timers in more 10 minutes to match the new expires at - jest.advanceTimersByTime(tenMinutesInMilliseconds); + jest.advanceTimersByTime(tenMinutesInMilliseconds - 1000); // should call the api await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2)); @@ -232,6 +185,7 @@ describe('useCookieAuthRefresh', () => { @@ -243,16 +197,10 @@ describe('useCookieAuthRefresh', () => { await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1)); - expect(result.current.state.result).toMatchObject({ expiresAt }); + expect(result.current.value).toMatchObject({ expiresAt }); unmount(); - expect(channelMock.removeEventListener).toHaveBeenCalledTimes(1); - expect(channelMock.removeEventListener).toHaveBeenCalledWith( - 'message', - expect.any(Function), - ); - // advance the timers to ensure that the refresh is not called jest.advanceTimersByTime(tenMinutesInMilliseconds); @@ -268,6 +216,7 @@ describe('useCookieAuthRefresh', () => { @@ -279,7 +228,7 @@ describe('useCookieAuthRefresh', () => { await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1)); // advance the timers to the expiration date - jest.advanceTimersByTime(tenMinutesInMilliseconds); + jest.advanceTimersByTime(tenMinutesInMilliseconds - 1000); // should call the api await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2)); diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index 2d495d54c4..8bf43f3e0c 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -14,51 +14,38 @@ * limitations under the License. */ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useCallback } from 'react'; import { discoveryApiRef, fetchApiRef, + storageApiRef, useApi, } from '@backstage/core-plugin-api'; import { useAsync, useMountEffect } from '@react-hookz/web'; import { ResponseError } from '@backstage/errors'; -type CookieAuthRefreshMessage = MessageEvent<{ - action: string; - payload: { - expiresAt: string; - }; -}>; - -/** - * @public - * 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({ - pluginId, - path = '/cookie', -}: CookieAuthRefreshOptions) { +export function useCookieAuthRefresh(params: { + // 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; + }; +}) { + const { pluginId, options: { path = '/cookie' } = {} } = params; + const fetchApi = useApi(fetchApiRef); + const storageApi = useApi(storageApiRef); const discoveryApi = useApi(discoveryApiRef); - const [channel] = useState( - () => new BroadcastChannel(`${pluginId}-auth-cookie-channel`), - ); + const store = storageApi.forBucket(`${pluginId}-auth-cookie-storage`); - const [state, actions] = useAsync(async () => { + const [state, actions] = useAsync<{ expiresAt: string }>(async () => { const apiOrigin = await discoveryApi.getBaseUrl(pluginId); const requestUrl = `${apiOrigin}${path}`; const response = await fetchApi.fetch(`${requestUrl}`, { @@ -86,28 +73,27 @@ export function useCookieAuthRefresh({ useEffect(() => { if (!state.result) return () => {}; - channel.postMessage({ - action: 'COOKIE_REFRESHED', - payload: state.result, - }); + store.set('expiresAt', state.result.expiresAt); let cancel = refresh(state.result); - const handleMessage = (event: CookieAuthRefreshMessage): void => { - const { action, payload } = event.data; - if (action === 'COOKIE_REFRESHED') { - cancel(); - cancel = refresh(payload); - } - }; - - channel.addEventListener('message', handleMessage); + const observable = store.observe$('expiresAt'); + const subscription = observable.subscribe(({ value }) => { + if (!value) return; + cancel(); + cancel = refresh({ expiresAt: value }); + }); return () => { cancel(); - channel.removeEventListener('message', handleMessage); + subscription.unsubscribe(); }; - }, [state, refresh, channel]); + }, [state, refresh, store]); - return { state, actions }; + return { + loading: state.status === 'loading', + error: state.error, + value: state.result, + retry: actions.execute, + }; } diff --git a/plugins/auth-react/src/setupTests.ts b/plugins/auth-react/src/setupTests.ts index 0ab6a810cf..658016ffdd 100644 --- a/plugins/auth-react/src/setupTests.ts +++ b/plugins/auth-react/src/setupTests.ts @@ -14,28 +14,3 @@ * limitations under the License. */ import '@testing-library/jest-dom'; - -type Listener = (event: { data: any }) => void; - -global.BroadcastChannel = jest - .fn() - .mockImplementation((_channelName: string) => { - let listeners: Listener[] = []; - return { - postMessage: jest.fn((message: any) => { - // Simulate message event for all listeners - listeners.forEach(listener => listener({ data: message })); - }), - addEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners.push(listener); - } - }), - removeEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners = listeners.filter(l => l !== listener); - } - }), - close: jest.fn(), - }; - }); diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index 51a4650f99..c3e7281116 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -22,12 +22,17 @@ import { screen } from 'testing-library__dom'; import { Route } from 'react-router-dom'; import { act, render } from '@testing-library/react'; -import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { + wrapInTestApp, + TestApiProvider, + MockStorageApi, +} from '@backstage/test-utils'; import { FlatRoutes } from '@backstage/core-app-api'; import { ApiRef, discoveryApiRef, fetchApiRef, + storageApiRef, } from '@backstage/core-plugin-api'; import { @@ -73,6 +78,8 @@ const scmIntegrationsApi = { fromConfig: jest.fn().mockReturnValue({}), }; +const storageApiMock = MockStorageApi.create(); + const discoveryApi = { getBaseUrl: jest .fn() @@ -225,6 +232,7 @@ export class TechDocsAddonTester { build() { const apis: TechdocsAddonTesterApis = [ [fetchApiRef, fetchApi], + [storageApiRef, storageApiMock], [discoveryApiRef, discoveryApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index 8295ea0688..abea660b62 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -19,6 +19,7 @@ import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { MockConfigApi, + MockStorageApi, renderInTestApp, TestApiProvider, } from '@backstage/test-utils'; @@ -38,6 +39,7 @@ import { configApiRef, discoveryApiRef, fetchApiRef, + storageApiRef, } from '@backstage/core-plugin-api'; const mockEntityMetadata = { @@ -80,6 +82,8 @@ const techdocsStorageApiMock: jest.Mocked = { syncEntityDocs: jest.fn(), }; +const storageApiMock = MockStorageApi.create(); + const discoveryApiMock = { getBaseUrl: jest .fn() @@ -117,6 +121,7 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => { ', () => { beforeEach(() => { - type Listener = (event: { data: any }) => void; - - global.BroadcastChannel = jest - .fn() - .mockImplementation((_channelName: string) => { - let listeners: Listener[] = []; - return { - postMessage: jest.fn((message: any) => { - listeners.forEach(listener => listener({ data: message })); - }), - addEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners.push(listener); - } - }), - removeEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners = listeners.filter(l => l !== listener); - } - }), - }; - }); - getEntityMetadata.mockResolvedValue(mockEntityMetadata); getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); getCookie.mockResolvedValue({ diff --git a/plugins/techdocs/src/setupTests.ts b/plugins/techdocs/src/setupTests.ts index 0bdcc73ccb..6c7fc2d3e3 100644 --- a/plugins/techdocs/src/setupTests.ts +++ b/plugins/techdocs/src/setupTests.ts @@ -17,28 +17,3 @@ import '@testing-library/jest-dom'; Element.prototype.scrollIntoView = jest.fn(); - -type Listener = (event: { data: any }) => void; - -global.BroadcastChannel = jest - .fn() - .mockImplementation((_channelName: string) => { - let listeners: Listener[] = []; - return { - postMessage: jest.fn((message: any) => { - // Simulate message event for all listeners - listeners.forEach(listener => listener({ data: message })); - }), - addEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners.push(listener); - } - }), - removeEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners = listeners.filter(l => l !== listener); - } - }), - close: jest.fn(), - }; - }); diff --git a/yarn.lock b/yarn.lock index 07c751cc3b..d432f7a958 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5120,7 +5120,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/test-utils": "workspace:^" "@material-ui/core": ^4.9.13 - "@react-hookz/web": ^24.0.4 + "@react-hookz/web": ^24.0.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 "@testing-library/user-event": ^14.0.0 @@ -15728,7 +15728,7 @@ __metadata: languageName: node linkType: hard -"@react-hookz/web@npm:^24.0.0, @react-hookz/web@npm:^24.0.4": +"@react-hookz/web@npm:^24.0.0": version: 24.0.4 resolution: "@react-hookz/web@npm:24.0.4" dependencies: From 1a557c14397d4a0de1a17ef4cf4bb95408eae543 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 15 Mar 2024 17:25:30 +0100 Subject: [PATCH 10/13] fix: refreshing statuses Signed-off-by: Camila Belo --- plugins/auth-react/api-report.md | 24 ++-- .../CookieAuthRefreshProvider.tsx | 15 +-- .../useCookieAuthRefresh.test.tsx | 110 +++++++++++++++++- .../useCookieAuthRefresh.tsx | 72 +++++++++--- 4 files changed, 175 insertions(+), 46 deletions(-) diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index 9c2cf4622d..660154271c 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -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; }; ``` diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 2b553f33e8..8f6f38e225 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -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 ; } - if (error) { + if (status === 'error' && error) { return ( diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx index 2d5d738348..5c754b584c 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -80,8 +80,6 @@ describe('useCookieAuthRefresh', () => { ); 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 () => { @@ -112,19 +110,25 @@ describe('useCookieAuthRefresh', () => { }, ); - expect(result.current.status).toBe('loading'); - expect(result.current.error).toBeUndefined(); - expect(result.current.result).toBeUndefined(); + expect(result.current).toStrictEqual({ status: 'loading' }); - await waitFor(() => expect(result.current.status).toBe('error')); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toStrictEqual(error); + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'error', + error, + retry: expect.any(Function), + }), + ); - result.current.retry(); + if (result.current.status === 'error') { + result.current.retry(); + } - await waitFor(() => expect(result.current.status).toBe('loading')); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toStrictEqual(error); + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'loading', + }), + ); }); it('should return a loading status when retrying with previous success', async () => { @@ -159,25 +163,34 @@ describe('useCookieAuthRefresh', () => { }, ); - expect(result.current.status).toBe('loading'); - expect(result.current.error).toBeUndefined(); - expect(result.current.result).toBeUndefined(); + expect(result.current).toStrictEqual({ status: 'loading' }); - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.result).toMatchObject({ expiresAt }); - expect(result.current.error).toBeUndefined(); + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'success', + data: { expiresAt }, + }), + ); - result.current.retry(); + jest.advanceTimersByTime(tenMinutesInMilliseconds); - await waitFor(() => expect(result.current.status).toBe('error')); - expect(result.current.result).toMatchObject({ expiresAt }); - expect(result.current.error).toStrictEqual(error); + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'error', + error, + retry: expect.any(Function), + }), + ); - result.current.retry(); + if (result.current.status === 'error') { + result.current.retry(); + } - await waitFor(() => expect(result.current.status).toBe('loading')); - expect(result.current.result).toMatchObject({ expiresAt }); - expect(result.current.error).toStrictEqual(error); + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'loading', + }), + ); }); it('should return an error status when the refresh has failed', async () => { @@ -205,7 +218,13 @@ describe('useCookieAuthRefresh', () => { }, ); - await waitFor(() => expect(result.current.error).toStrictEqual(error)); + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'error', + error, + retry: expect.any(Function), + }), + ); }); it('should call the api to get the cookie and use it', async () => { @@ -233,7 +252,12 @@ describe('useCookieAuthRefresh', () => { ), ); - expect(result.current.result).toMatchObject({ expiresAt }); + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'success', + data: { expiresAt }, + }), + ); }); it('should cancel the refresh when a message is received from another tab', async () => { @@ -295,9 +319,14 @@ describe('useCookieAuthRefresh', () => { }, ); - await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'success', + data: { expiresAt }, + }), + ); - expect(result.current.result).toMatchObject({ expiresAt }); + await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1)); unmount(); diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index c7b4984928..79e80c3954 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useCallback, useMemo } from 'react'; +import { useEffect, useCallback } from 'react'; import { discoveryApiRef, fetchApiRef, @@ -34,14 +34,10 @@ export function useCookieAuthRefresh(options: { pluginId: 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; - }; - retry: () => void; -} { +}): + | { status: 'loading' } + | { status: 'error'; error: Error; retry: () => void } + | { status: 'success'; data: { expiresAt: string } } { const { pluginId, path = '/cookie' } = options ?? {}; const fetchApi = useApi(fetchApiRef); const storageApi = useApi(storageApiRef); @@ -58,14 +54,19 @@ export function useCookieAuthRefresh(options: { if (!response.ok) { throw await ResponseError.fromResponse(response); } - return await response.json(); + const data = await response.json(); + if (!data.expiresAt) { + throw new Error('No expiration date found in response'); + } + return data; }); useMountEffect(actions.execute); const refresh = useCallback( (params: { expiresAt: string }) => { - // Randomize the refreshing margin to avoid all tabs refreshing at the same time + // Randomize the refreshing margin with a margin of 1-4 minutes to avoid all tabs refreshing at the same time + // It cannot be less than 5 minutes otherwise the backend will return the same expiration date const margin = (1 + 3 * Math.random()) * 60000; const delay = Date.parse(params.expiresAt) - Date.now() - margin; const timeout = setTimeout(actions.execute, delay); @@ -97,43 +98,35 @@ export function useCookieAuthRefresh(options: { }; }, [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 { - retry, - status, - result: state.result, - error: state.error, - }; + // Initialising + if (state.status === 'not-executed') { + return { status: 'loading' }; + } + + // First refresh or retrying without any success before + // Possible state 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 { status: 'loading' }; + } + + // Retrying after having succeeding at least once + // Current state 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 { status: 'loading' }; + } + + // Something went wrong during any situation of a refresh + if (state.status === 'error' && state.error) { + return { status: 'error', error: state.error, retry }; + } + + // At this point it should be safe to assume that we have a successful refresh + return { status: 'success', data: state.result! }; } From cfc0bb345d01e78acfc040c46e719a340db1fd02 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 18 Mar 2024 08:32:40 +0100 Subject: [PATCH 12/13] fix: some types Signed-off-by: Camila Belo --- .../CookieAuthRefreshProvider.tsx | 4 +- .../useCookieAuthRefresh.tsx | 40 +++++++++---------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 85846b28c9..7919207692 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -25,9 +25,9 @@ import { useCookieAuthRefresh } from '../../hooks'; * Props for the {@link CookieAuthRefreshProvider} component. */ export type CookieAuthRefreshProviderProps = { - // The plugin ID to used for discovering the API origin + // The plugin ID used for discovering the API origin pluginId: string; - // The path to used for calling the refresh cookie endpoint, default to '/cookie' + // The path used for calling the refresh cookie endpoint, default to '/cookie' path?: string; // The children to render when the refresh is successful children: ReactNode; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index 79e80c3954..b5b5444e71 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -30,9 +30,9 @@ import { ResponseError } from '@backstage/errors'; * @param options - Options for configuring the refresh cookie endpoint */ export function useCookieAuthRefresh(options: { - // The plugin id to used for discovering the API origin + // The plugin id used for discovering the API origin pluginId: string; - // The path to used for calling the refresh cookie endpoint, default to '/cookie' + // The path used for calling the refresh cookie endpoint, default to '/cookie' path?: string; }): | { status: 'loading' } @@ -63,45 +63,43 @@ export function useCookieAuthRefresh(options: { useMountEffect(actions.execute); + const retry = useCallback(() => { + actions.execute(); + }, [actions]); + const refresh = useCallback( (params: { expiresAt: string }) => { // Randomize the refreshing margin with a margin of 1-4 minutes to avoid all tabs refreshing at the same time // It cannot be less than 5 minutes otherwise the backend will return the same expiration date const margin = (1 + 3 * Math.random()) * 60000; const delay = Date.parse(params.expiresAt) - Date.now() - margin; - const timeout = setTimeout(actions.execute, delay); + const timeout = setTimeout(retry, delay); return () => clearTimeout(timeout); }, - [actions], + [retry], ); useEffect(() => { - // Only start the refresh process if we have a successful response + // Only schedule a refresh if we have a successful response if (state.status !== 'success' || !state.result) { return () => {}; } - - store.set('expiresAt', state.result.expiresAt); - - let cancel = refresh(state.result); - - const observable = store.observe$('expiresAt'); - const subscription = observable.subscribe(({ value }) => { - if (!value) return; - cancel(); - cancel = refresh({ expiresAt: value }); - }); - + const expiresAt = state.result.expiresAt; + store.set('expiresAt', expiresAt); + let cancel = refresh({ expiresAt }); + const subscription = store + .observe$('expiresAt') + .subscribe(({ value }) => { + if (!value) return; + cancel(); + cancel = refresh({ expiresAt: value }); + }); return () => { cancel(); subscription.unsubscribe(); }; }, [state, refresh, store]); - const retry = useCallback(() => { - actions.execute(); - }, [actions]); - // Initialising if (state.status === 'not-executed') { return { status: 'loading' }; From 13c680c165987c655b081f2aa385a8d1c7bb4c0e Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 18 Mar 2024 16:54:35 +0100 Subject: [PATCH 13/13] refactor: use broadcast channel again Signed-off-by: Camila Belo --- .../useCookieAuthRefresh.test.tsx | 32 +++++----------- .../useCookieAuthRefresh.tsx | 37 +++++++++++-------- plugins/auth-react/src/setupTests.ts | 33 +++++++++++++++++ .../src/test-utils.tsx | 10 +---- .../TechDocsReaderPage.test.tsx | 5 --- 5 files changed, 66 insertions(+), 51 deletions(-) diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx index 5c754b584c..180de98a46 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -16,12 +16,8 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react'; -import { - fetchApiRef, - discoveryApiRef, - storageApiRef, -} from '@backstage/core-plugin-api'; -import { MockStorageApi, TestApiProvider } from '@backstage/test-utils'; +import { fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; import { useCookieAuthRefresh } from './useCookieAuthRefresh'; describe('useCookieAuthRefresh', () => { @@ -43,8 +39,6 @@ describe('useCookieAuthRefresh', () => { }), }; - const storageApiMock = MockStorageApi.create(); - beforeEach(() => { jest.useFakeTimers({ now }); jest.clearAllMocks(); @@ -69,7 +63,6 @@ describe('useCookieAuthRefresh', () => { fetch: jest.fn().mockRejectedValue(error), }, ], - [storageApiRef, storageApiMock], [discoveryApiRef, discoveryApiMock], ]} > @@ -100,7 +93,6 @@ describe('useCookieAuthRefresh', () => { .mockReturnValue(new Promise(() => {})), }, ], - [storageApiRef, storageApiMock], [discoveryApiRef, discoveryApiMock], ]} > @@ -153,7 +145,6 @@ describe('useCookieAuthRefresh', () => { .mockReturnValue(new Promise(() => {})), }, ], - [storageApiRef, storageApiMock], [discoveryApiRef, discoveryApiMock], ]} > @@ -208,7 +199,6 @@ describe('useCookieAuthRefresh', () => { fetch: jest.fn().mockRejectedValue(error), }, ], - [storageApiRef, storageApiMock], [discoveryApiRef, discoveryApiMock], ]} > @@ -235,7 +225,6 @@ describe('useCookieAuthRefresh', () => { @@ -268,7 +257,6 @@ describe('useCookieAuthRefresh', () => { @@ -281,12 +269,14 @@ describe('useCookieAuthRefresh', () => { now + 2 * tenMinutesInMilliseconds; // simulating other tab refreshing the cookie - storageApiMock - .forBucket(`${pluginId}-auth-cookie-storage`) - .set( - 'expiresAt', - new Date(twentyMinutesFromNowInMilliseconds).toISOString(), - ); + new global.BroadcastChannel( + `${pluginId}-auth-cookie-expires-at`, + ).postMessage({ + action: 'COOKIE_REFRESH_SUCCESS', + payload: { + expiresAt: new Date(twentyMinutesFromNowInMilliseconds).toISOString(), + }, + }); // advance the timers in 10 minutes to match the old expires at jest.advanceTimersByTime(tenMinutesInMilliseconds); @@ -309,7 +299,6 @@ describe('useCookieAuthRefresh', () => { @@ -345,7 +334,6 @@ describe('useCookieAuthRefresh', () => { diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index b5b5444e71..dfb13289db 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -14,11 +14,10 @@ * limitations under the License. */ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useMemo } from 'react'; import { discoveryApiRef, fetchApiRef, - storageApiRef, useApi, } from '@backstage/core-plugin-api'; import { useAsync, useMountEffect } from '@react-hookz/web'; @@ -40,10 +39,13 @@ export function useCookieAuthRefresh(options: { | { status: 'success'; data: { expiresAt: string } } { const { pluginId, path = '/cookie' } = options ?? {}; const fetchApi = useApi(fetchApiRef); - const storageApi = useApi(storageApiRef); const discoveryApi = useApi(discoveryApiRef); - const store = storageApi.forBucket(`${pluginId}-auth-cookie-storage`); + const channel = useMemo(() => { + return 'BroadcastChannel' in window + ? new BroadcastChannel(`${pluginId}-auth-cookie-expires-at`) + : null; + }, [pluginId]); const [state, actions] = useAsync<{ expiresAt: string }>(async () => { const apiOrigin = await discoveryApi.getBaseUrl(pluginId); @@ -84,21 +86,26 @@ export function useCookieAuthRefresh(options: { if (state.status !== 'success' || !state.result) { return () => {}; } - const expiresAt = state.result.expiresAt; - store.set('expiresAt', expiresAt); - let cancel = refresh({ expiresAt }); - const subscription = store - .observe$('expiresAt') - .subscribe(({ value }) => { - if (!value) return; + channel?.postMessage({ + action: 'COOKIE_REFRESH_SUCCESS', + payload: state.result, + }); + let cancel = refresh(state.result); + const listener = ( + event: MessageEvent<{ action: string; payload: { expiresAt: string } }>, + ) => { + const { action, payload } = event.data; + if (action === 'COOKIE_REFRESH_SUCCESS') { cancel(); - cancel = refresh({ expiresAt: value }); - }); + cancel = refresh(payload); + } + }; + channel?.addEventListener('message', listener); return () => { cancel(); - subscription.unsubscribe(); + channel?.removeEventListener('message', listener); }; - }, [state, refresh, store]); + }, [state, refresh, channel]); // Initialising if (state.status === 'not-executed') { diff --git a/plugins/auth-react/src/setupTests.ts b/plugins/auth-react/src/setupTests.ts index 658016ffdd..6580ddffad 100644 --- a/plugins/auth-react/src/setupTests.ts +++ b/plugins/auth-react/src/setupTests.ts @@ -14,3 +14,36 @@ * limitations under the License. */ import '@testing-library/jest-dom'; + +global.BroadcastChannel = jest + .fn() + .mockImplementation((_channelName: string) => { + const listeners: ((event: { data: any }) => void)[] = []; + + return { + addEventListener: ( + type: string, + listener: (event: { data: any }) => void, + ) => { + if (type === 'message') { + listeners.push(listener); + } + }, + removeEventListener: ( + type: string, + listener: (event: { data: any }) => void, + ) => { + if (type === 'message') { + const index = listeners.indexOf(listener); + if (index !== -1) { + listeners.splice(index, 1); + } + } + }, + postMessage: (message: any) => { + listeners.forEach(listener => { + listener({ data: message }); + }); + }, + }; + }); diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index c3e7281116..51a4650f99 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -22,17 +22,12 @@ import { screen } from 'testing-library__dom'; import { Route } from 'react-router-dom'; import { act, render } from '@testing-library/react'; -import { - wrapInTestApp, - TestApiProvider, - MockStorageApi, -} from '@backstage/test-utils'; +import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { FlatRoutes } from '@backstage/core-app-api'; import { ApiRef, discoveryApiRef, fetchApiRef, - storageApiRef, } from '@backstage/core-plugin-api'; import { @@ -78,8 +73,6 @@ const scmIntegrationsApi = { fromConfig: jest.fn().mockReturnValue({}), }; -const storageApiMock = MockStorageApi.create(); - const discoveryApi = { getBaseUrl: jest .fn() @@ -232,7 +225,6 @@ export class TechDocsAddonTester { build() { const apis: TechdocsAddonTesterApis = [ [fetchApiRef, fetchApi], - [storageApiRef, storageApiMock], [discoveryApiRef, discoveryApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index abea660b62..ed8b05c661 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -19,7 +19,6 @@ import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { MockConfigApi, - MockStorageApi, renderInTestApp, TestApiProvider, } from '@backstage/test-utils'; @@ -39,7 +38,6 @@ import { configApiRef, discoveryApiRef, fetchApiRef, - storageApiRef, } from '@backstage/core-plugin-api'; const mockEntityMetadata = { @@ -82,8 +80,6 @@ const techdocsStorageApiMock: jest.Mocked = { syncEntityDocs: jest.fn(), }; -const storageApiMock = MockStorageApi.create(); - const discoveryApiMock = { getBaseUrl: jest .fn() @@ -121,7 +117,6 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => {