0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

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
This commit is contained in:
simeng-li 2023-11-21 16:09:07 +08:00 committed by GitHub
parent 21af052321
commit fbf545ecd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 130 additions and 31 deletions

View file

@ -45,6 +45,7 @@
"@swc/core": "^1.3.52", "@swc/core": "^1.3.52",
"@swc/jest": "^0.2.26", "@swc/jest": "^0.2.26",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/jest": "^29.4.0", "@types/jest": "^29.4.0",
"@types/react": "^18.0.31", "@types/react": "^18.0.31",

View file

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

View file

@ -1,9 +1,10 @@
/** /**
* Provides a hook to access the session storage. * Provides a hook to access the session storage.
*/ */
import { type SsoConnectorMetadata } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { useCallback } from 'react'; import { useCallback } from 'react';
import * as s from 'superstruct';
import { ssoConnectorMetadataGuard } from '@/types/guard';
const logtoStorageKeyPrefix = `logto:${window.location.origin}`; const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
@ -12,19 +13,16 @@ export enum StorageKeys {
SsoConnectors = 'sso-connectors', SsoConnectors = 'sso-connectors',
} }
const valueType = Object.freeze({ const valueGuard = Object.freeze({
[StorageKeys.SsoEmail]: 'string', [StorageKeys.SsoEmail]: s.string(),
[StorageKeys.SsoConnectors]: 'object', [StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard),
} satisfies { [key in StorageKeys]: 'string' | 'object' }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details
} satisfies { [key in StorageKeys]: s.Struct<any> });
type StorageValue<K extends StorageKeys> = K extends StorageKeys.SsoEmail type StorageValueType<K extends StorageKeys> = s.Infer<(typeof valueGuard)[K]>;
? string
: K extends StorageKeys.SsoConnectors
? SsoConnectorMetadata[]
: never;
const useSessionStorage = () => { const useSessionStorage = () => {
const set = useCallback(<T extends StorageKeys>(key: T, value: StorageValue<T>) => { const set = useCallback(<T extends StorageKeys>(key: T, value: StorageValueType<T>) => {
if (typeof value === 'object') { if (typeof value === 'object') {
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, JSON.stringify(value)); sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, JSON.stringify(value));
return; return;
@ -33,28 +31,40 @@ const useSessionStorage = () => {
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, value); sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, value);
}, []); }, []);
const get = useCallback(<T extends StorageKeys>(key: T): StorageValue<T> | 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<T>
);
}
// eslint-disable-next-line no-restricted-syntax
return value as StorageValue<T>;
}, []);
const remove = useCallback((key: StorageKeys) => { const remove = useCallback((key: StorageKeys) => {
sessionStorage.removeItem(`${logtoStorageKeyPrefix}:${key}`); sessionStorage.removeItem(`${logtoStorageKeyPrefix}:${key}`);
}, []); }, []);
const get = useCallback(
<T extends StorageKeys>(key: T): StorageValueType<T> | 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 }; return { set, get, remove };
}; };

View file

@ -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 * as s from 'superstruct';
import { UserFlow } from '.'; import { UserFlow } from '.';
@ -106,3 +111,11 @@ export const webAuthnStateGuard = s.assign(
); );
export type WebAuthnState = s.Infer<typeof webAuthnStateGuard>; export type WebAuthnState = s.Infer<typeof webAuthnStateGuard>;
/* Single Sign On */
export const ssoConnectorMetadataGuard: s.Describe<SsoConnectorMetadata> = s.object({
id: s.string(),
logo: s.string(),
darkLogo: s.optional(s.string()),
connectorName: s.string(),
});

View file

@ -3584,6 +3584,9 @@ importers:
'@testing-library/react': '@testing-library/react':
specifier: ^14.0.0 specifier: ^14.0.0
version: 14.0.0(react-dom@18.2.0)(react@18.2.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': '@types/color':
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@ -9617,6 +9620,29 @@ packages:
pretty-format: 27.5.1 pretty-format: 27.5.1
dev: true 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): /@testing-library/react@14.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==} resolution: {integrity: sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -18179,6 +18205,16 @@ packages:
react: 18.2.0 react: 18.2.0
dev: true 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: /react-error-overlay@6.0.9:
resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==} resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==}
dev: true dev: true