From fbf545ecd404b68dbfe08085eb645476083c48a9 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Tue, 21 Nov 2023 16:09:07 +0800 Subject: [PATCH] feat(experience): add struct guard to session storage hook (#4933) * feat(experience): add struct guard to session storage hook add struct guard to the session storage hook * test(experience): add unit test for the hook add unit test for the session storages hook --- packages/experience/package.json | 1 + .../src/hooks/use-session-storages.test.ts | 39 +++++++++++ .../src/hooks/use-session-storages.ts | 70 +++++++++++-------- packages/experience/src/types/guard.ts | 15 +++- pnpm-lock.yaml | 36 ++++++++++ 5 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 packages/experience/src/hooks/use-session-storages.test.ts diff --git a/packages/experience/package.json b/packages/experience/package.json index e047982c9..6e6d37d8b 100644 --- a/packages/experience/package.json +++ b/packages/experience/package.json @@ -45,6 +45,7 @@ "@swc/core": "^1.3.52", "@swc/jest": "^0.2.26", "@testing-library/react": "^14.0.0", + "@testing-library/react-hooks": "^8.0.1", "@types/color": "^3.0.3", "@types/jest": "^29.4.0", "@types/react": "^18.0.31", diff --git a/packages/experience/src/hooks/use-session-storages.test.ts b/packages/experience/src/hooks/use-session-storages.test.ts new file mode 100644 index 000000000..c7d224b06 --- /dev/null +++ b/packages/experience/src/hooks/use-session-storages.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { mockSsoConnectors } from '@/__mocks__/logto'; + +import useSessionStorage, { StorageKeys } from './use-session-storages'; + +describe('useSessionStorage', () => { + it('should set and get a email value', () => { + const email = 'foo@test.io'; + const { result } = renderHook(() => useSessionStorage()); + const { get, set, remove } = result.current; + + expect(get(StorageKeys.SsoEmail)).toBeUndefined(); + set(StorageKeys.SsoEmail, email); + expect(get(StorageKeys.SsoEmail)).toBe(email); + remove(StorageKeys.SsoEmail); + expect(get(StorageKeys.SsoEmail)).toBeUndefined(); + }); + + it('should set and get a sso connectors value', () => { + const { result } = renderHook(() => useSessionStorage()); + const { get, set, remove } = result.current; + + expect(get(StorageKeys.SsoConnectors)).toBeUndefined(); + set(StorageKeys.SsoConnectors, mockSsoConnectors); + expect(get(StorageKeys.SsoConnectors)).toStrictEqual(mockSsoConnectors); + remove(StorageKeys.SsoConnectors); + expect(get(StorageKeys.SsoConnectors)).toBeUndefined(); + }); + + it('should return undefined if the value is invalid', () => { + const { result } = renderHook(() => useSessionStorage()); + const { get, set } = result.current; + + // @ts-expect-error -- we are testing invalid values + set(StorageKeys.SsoConnectors, 'foo'); + expect(get(StorageKeys.SsoEmail)).toBeUndefined(); + }); +}); diff --git a/packages/experience/src/hooks/use-session-storages.ts b/packages/experience/src/hooks/use-session-storages.ts index c653fa05c..b492f16e6 100644 --- a/packages/experience/src/hooks/use-session-storages.ts +++ b/packages/experience/src/hooks/use-session-storages.ts @@ -1,9 +1,10 @@ /** * Provides a hook to access the session storage. */ -import { type SsoConnectorMetadata } from '@logto/schemas'; -import { trySafe } from '@silverhand/essentials'; import { useCallback } from 'react'; +import * as s from 'superstruct'; + +import { ssoConnectorMetadataGuard } from '@/types/guard'; const logtoStorageKeyPrefix = `logto:${window.location.origin}`; @@ -12,19 +13,16 @@ export enum StorageKeys { SsoConnectors = 'sso-connectors', } -const valueType = Object.freeze({ - [StorageKeys.SsoEmail]: 'string', - [StorageKeys.SsoConnectors]: 'object', -} satisfies { [key in StorageKeys]: 'string' | 'object' }); +const valueGuard = Object.freeze({ + [StorageKeys.SsoEmail]: s.string(), + [StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard), + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details +} satisfies { [key in StorageKeys]: s.Struct }); -type StorageValue = K extends StorageKeys.SsoEmail - ? string - : K extends StorageKeys.SsoConnectors - ? SsoConnectorMetadata[] - : never; +type StorageValueType = s.Infer<(typeof valueGuard)[K]>; const useSessionStorage = () => { - const set = useCallback((key: T, value: StorageValue) => { + const set = useCallback((key: T, value: StorageValueType) => { if (typeof value === 'object') { sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, JSON.stringify(value)); return; @@ -33,28 +31,40 @@ const useSessionStorage = () => { sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, value); }, []); - const get = useCallback((key: T): StorageValue | undefined => { - const value = sessionStorage.getItem(`${logtoStorageKeyPrefix}:${key}`); - - if (value === null) { - return; - } - - if (valueType[key] === 'object') { - return trySafe( - // eslint-disable-next-line no-restricted-syntax - () => JSON.parse(value) as StorageValue - ); - } - - // eslint-disable-next-line no-restricted-syntax - return value as StorageValue; - }, []); - const remove = useCallback((key: StorageKeys) => { sessionStorage.removeItem(`${logtoStorageKeyPrefix}:${key}`); }, []); + const get = useCallback( + (key: T): StorageValueType | undefined => { + const value = sessionStorage.getItem(`${logtoStorageKeyPrefix}:${key}`); + + if (value === null) { + return; + } + + const [error, rawValue] = valueGuard[key].validate( + (() => { + try { + // eslint-disable-next-line no-restricted-syntax -- we use superstruct to validate the value + return JSON.parse(value) as unknown; + } catch { + return value; + } + })() + ); + + if (error) { + // Clear the invalid value + remove(key); + return; + } + + return rawValue; + }, + [remove] + ); + return { set, get, remove }; }; diff --git a/packages/experience/src/types/guard.ts b/packages/experience/src/types/guard.ts index 078b4d4fb..d52ab1723 100644 --- a/packages/experience/src/types/guard.ts +++ b/packages/experience/src/types/guard.ts @@ -1,4 +1,9 @@ -import { SignInIdentifier, MissingProfile, MfaFactor } from '@logto/schemas'; +import { + SignInIdentifier, + MissingProfile, + MfaFactor, + type SsoConnectorMetadata, +} from '@logto/schemas'; import * as s from 'superstruct'; import { UserFlow } from '.'; @@ -106,3 +111,11 @@ export const webAuthnStateGuard = s.assign( ); export type WebAuthnState = s.Infer; + +/* Single Sign On */ +export const ssoConnectorMetadataGuard: s.Describe = s.object({ + id: s.string(), + logo: s.string(), + darkLogo: s.optional(s.string()), + connectorName: s.string(), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2ebdabc4..f4c87024e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3584,6 +3584,9 @@ importers: '@testing-library/react': specifier: ^14.0.0 version: 14.0.0(react-dom@18.2.0)(react@18.2.0) + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(@types/react@18.0.31)(react-dom@18.2.0)(react@18.2.0) '@types/color': specifier: ^3.0.3 version: 3.0.3 @@ -9617,6 +9620,29 @@ packages: pretty-format: 27.5.1 dev: true + /@testing-library/react-hooks@8.0.1(@types/react@18.0.31)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} + engines: {node: '>=12'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + dependencies: + '@babel/runtime': 7.21.0 + '@types/react': 18.0.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-error-boundary: 3.1.4(react@18.2.0) + dev: true + /@testing-library/react@14.0.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==} engines: {node: '>=14'} @@ -18179,6 +18205,16 @@ packages: react: 18.2.0 dev: true + /react-error-boundary@3.1.4(react@18.2.0): + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1 || ^18.0.0' + dependencies: + '@babel/runtime': 7.21.0 + react: 18.2.0 + dev: true + /react-error-overlay@6.0.9: resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==} dev: true