diff --git a/admin-ui/app/components/Accordion/Accordion.tsx b/admin-ui/app/components/Accordion/Accordion.tsx index 39abfb72a..7ddb3393b 100755 --- a/admin-ui/app/components/Accordion/Accordion.tsx +++ b/admin-ui/app/components/Accordion/Accordion.tsx @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react' import { AccordionProps } from './types' import Card from './../Card' -import { Provider } from './context' +import { AccordionContext } from './context' export const Accordion = (props: AccordionProps) => { const { className, children, initialOpen, open, onToggle, ...otherProps } = props @@ -26,7 +26,7 @@ export const Accordion = (props: AccordionProps) => { const isOpen = onToggle ? (open ?? false) : isOpenState return ( - { {children} - + ) } diff --git a/admin-ui/app/components/Accordion/AccordionBody.tsx b/admin-ui/app/components/Accordion/AccordionBody.tsx index 11f892ed8..2219a20ab 100755 --- a/admin-ui/app/components/Accordion/AccordionBody.tsx +++ b/admin-ui/app/components/Accordion/AccordionBody.tsx @@ -1,16 +1,15 @@ -import React from 'react' +import React, { use } from 'react' import clsx from 'clsx' import Collapse from '@mui/material/Collapse' -import { Consumer } from './context' +import { AccordionContext } from './context' import type { AccordionBodyProps } from './types' -export const AccordionBody: React.FC = ({ children, className }) => ( - - {({ isOpen }) => ( - -
{children}
-
- )} -
-) +export const AccordionBody: React.FC = ({ children, className }) => { + const { isOpen } = use(AccordionContext) + return ( + +
{children}
+
+ ) +} diff --git a/admin-ui/app/components/Accordion/AccordionHeader.tsx b/admin-ui/app/components/Accordion/AccordionHeader.tsx index ff33ef429..cd5bca389 100755 --- a/admin-ui/app/components/Accordion/AccordionHeader.tsx +++ b/admin-ui/app/components/Accordion/AccordionHeader.tsx @@ -1,26 +1,15 @@ -import React from 'react' +import React, { use } from 'react' import clsx from 'clsx' import CardHeader from './../CardHeader' -import { Consumer } from './context' +import { AccordionContext } from './context' import classes from './AccordionHeader.module.scss' import { AccordionHeaderProps } from './types' -interface AccordionContext { - onToggle: () => void +export const AccordionHeader: React.FC = (props) => { + const { onToggle } = use(AccordionContext) + return ( + + {props.children} + + ) } - -export const AccordionHeader: React.FC = (props) => ( - - {(context) => { - const typedContext = context as AccordionContext - return ( - - {props.children} - - ) - }} - -) diff --git a/admin-ui/app/components/Accordion/AccordionIndicator.tsx b/admin-ui/app/components/Accordion/AccordionIndicator.tsx index 28e122f95..509bcdfbc 100755 --- a/admin-ui/app/components/Accordion/AccordionIndicator.tsx +++ b/admin-ui/app/components/Accordion/AccordionIndicator.tsx @@ -1,8 +1,8 @@ -import React, { type ReactElement } from 'react' +import React, { use, type ReactElement } from 'react' import clsx from 'clsx' import { Add, Remove } from '@/components/icons' -import { Consumer } from './context' +import { AccordionContext } from './context' type IndicatorElement = ReactElement<{ className?: string }> @@ -16,13 +16,10 @@ export const AccordionIndicator: React.FC = ({ open = , closed = , className, -}) => ( - - {({ isOpen }) => { - const indicator = isOpen ? open : closed - return React.cloneElement(indicator, { - className: clsx(className, indicator.props.className), - }) - }} - -) +}) => { + const { isOpen } = use(AccordionContext) + const indicator = isOpen ? open : closed + return React.cloneElement(indicator, { + className: clsx(className, indicator.props.className), + }) +} diff --git a/admin-ui/app/components/Accordion/context.tsx b/admin-ui/app/components/Accordion/context.tsx index c96fe3020..c7c964703 100755 --- a/admin-ui/app/components/Accordion/context.tsx +++ b/admin-ui/app/components/Accordion/context.tsx @@ -1,12 +1,9 @@ -import React from 'react' +import { createContext } from 'react' +import type { AccordionContextType } from './types' -interface AccordionContextType { - isOpen: boolean - onToggle: () => void -} -const { Provider, Consumer } = React.createContext({ +const AccordionContext = createContext({ isOpen: false, onToggle: () => {}, }) -export { Provider, Consumer } +export { AccordionContext } diff --git a/admin-ui/app/components/Accordion/types.ts b/admin-ui/app/components/Accordion/types.ts index 2dd8a542f..1938fb4ac 100644 --- a/admin-ui/app/components/Accordion/types.ts +++ b/admin-ui/app/components/Accordion/types.ts @@ -18,3 +18,8 @@ export type AccordionBodyProps = { children?: React.ReactNode className?: string } + +export type AccordionContextType = { + isOpen: boolean + onToggle: () => void +} diff --git a/admin-ui/app/components/GluuDropdown/GluuDropdown.tsx b/admin-ui/app/components/GluuDropdown/GluuDropdown.tsx index 14fb2917e..a298a67c2 100644 --- a/admin-ui/app/components/GluuDropdown/GluuDropdown.tsx +++ b/admin-ui/app/components/GluuDropdown/GluuDropdown.tsx @@ -8,6 +8,22 @@ import { NO_TEXT_SELECT } from './sharedDropdownStyles' import { useStyles } from './GluuDropdown.style' import type { DropdownValue, GluuDropdownOption, GluuDropdownProps, DropdownState } from './types' +const extractTextFromReactNode = (node: React.ReactNode): string => { + if (typeof node === 'string') return node + if (typeof node === 'number') return String(node) + if (React.isValidElement<{ children?: React.ReactNode }>(node) && node.props.children) { + const { children } = node.props + if (Array.isArray(children)) { + return children.map((child) => extractTextFromReactNode(child)).join('') + } + return extractTextFromReactNode(children) + } + if (Array.isArray(node)) { + return node.map((child) => extractTextFromReactNode(child)).join('') + } + return '' +} + export const GluuDropdown = ({ trigger, options, @@ -65,22 +81,6 @@ export const GluuDropdown = ({ [controlled, onOpenChange], ) - const extractTextFromReactNode = useCallback((node: React.ReactNode): string => { - if (typeof node === 'string') return node - if (typeof node === 'number') return String(node) - if (React.isValidElement<{ children?: React.ReactNode }>(node) && node.props.children) { - const { children } = node.props - if (Array.isArray(children)) { - return children.map((child) => extractTextFromReactNode(child)).join('') - } - return extractTextFromReactNode(children) - } - if (Array.isArray(node)) { - return node.map((child) => extractTextFromReactNode(child)).join('') - } - return '' - }, []) - const filteredOptions = useMemo(() => { if (!searchable || !searchQuery.trim()) { return options @@ -96,7 +96,7 @@ export const GluuDropdown = ({ : extractTextFromReactNode(option.label).toLowerCase() return searchText.includes(query) }) - }, [options, searchQuery, searchable, extractTextFromReactNode]) + }, [options, searchQuery, searchable]) const isSelected = useCallback( (value: T): boolean => { diff --git a/admin-ui/app/components/SidebarMenu/SidebarMenuItem.tsx b/admin-ui/app/components/SidebarMenu/SidebarMenuItem.tsx index c9e00cdb6..052934932 100755 --- a/admin-ui/app/components/SidebarMenu/SidebarMenuItem.tsx +++ b/admin-ui/app/components/SidebarMenu/SidebarMenuItem.tsx @@ -77,7 +77,8 @@ export const SidebarMenuItem: React.FC = ({ sidebarMenuActiveClass, isEmptyNode = false, }) => { - const { entries, addEntry, updateEntry, removeEntry } = use(MenuContext) + const menuContext = use(MenuContext) + const { entries, addEntry, updateEntry, removeEntry } = menuContext const theme = use(ThemeContext) const selectedTheme = theme?.state.theme || DEFAULT_THEME const id = useId() @@ -201,24 +202,17 @@ export const SidebarMenuItem: React.FC = ({ {children && (
    {React.Children.map(children, (child) => - child ? ( - - {(ctx: SidebarMenuContext) => - React.isValidElement(child) - ? React.cloneElement( - child as React.ReactElement, - { - isSubNode: true, - parentId: id, - currentUrl, - slim, - ...ctx, - }, - ) - : child - } - - ) : null, + child && React.isValidElement(child) + ? React.cloneElement( + child as React.ReactElement, + { + isSubNode: true, + parentId: id, + currentUrl, + slim, + }, + ) + : child, )}
)} diff --git a/admin-ui/app/components/Theme/ThemeClass.tsx b/admin-ui/app/components/Theme/ThemeClass.tsx index 44f0c992a..5b572f79d 100755 --- a/admin-ui/app/components/Theme/ThemeClass.tsx +++ b/admin-ui/app/components/Theme/ThemeClass.tsx @@ -1,6 +1,5 @@ -import React from 'react' -import { Consumer } from './ThemeContext' -import type { ThemeContextState } from './types' +import React, { use } from 'react' +import { ThemeContext } from './ThemeContext' interface ThemeClassProps { children: (layoutThemeClass: string) => React.ReactNode @@ -15,12 +14,9 @@ const ThemeClass: React.FC = ({ children, color, style }) => { const ContextThemeClass: React.FC< Omit & { children: (layoutThemeClass: string) => React.ReactNode } -> = (otherProps) => ( - - {(themeState: ThemeContextState | null) => - themeState ? : null - } - -) +> = (otherProps) => { + const themeState = use(ThemeContext) + return themeState ? : null +} export { ContextThemeClass as ThemeClass } diff --git a/admin-ui/app/components/Theme/ThemeContext.tsx b/admin-ui/app/components/Theme/ThemeContext.tsx index e6ae09ab4..b7e9236f9 100755 --- a/admin-ui/app/components/Theme/ThemeContext.tsx +++ b/admin-ui/app/components/Theme/ThemeContext.tsx @@ -2,6 +2,6 @@ import React from 'react' import type { ThemeContextState } from './types' -const { Provider, Consumer } = React.createContext(null) +const ThemeContext = React.createContext(null) -export { Provider, Consumer } +export { ThemeContext } diff --git a/admin-ui/app/components/Theme/ThemeProvider.tsx b/admin-ui/app/components/Theme/ThemeProvider.tsx index afba39302..67386c935 100755 --- a/admin-ui/app/components/Theme/ThemeProvider.tsx +++ b/admin-ui/app/components/Theme/ThemeProvider.tsx @@ -1,6 +1,6 @@ import React, { ReactNode } from 'react' -import { Provider } from './ThemeContext' +import { ThemeContext } from './ThemeContext' import { THEME_LIGHT } from '@/context/theme/constants' interface ThemeProviderProps { @@ -46,14 +46,14 @@ export class ThemeProvider extends React.Component {children} - + ) } } diff --git a/admin-ui/app/context/theme/themeContext.tsx b/admin-ui/app/context/theme/themeContext.tsx index 54c9600a2..c5b74648e 100644 --- a/admin-ui/app/context/theme/themeContext.tsx +++ b/admin-ui/app/context/theme/themeContext.tsx @@ -41,6 +41,15 @@ const extractUserTheme = (currentInum?: string | null): ThemeValue => { } } +export const getStoredTheme = (): ThemeValue => { + if (typeof window === 'undefined') { + return DEFAULT_THEME + } + + const savedTheme = storage.get(STORAGE_KEYS.INIT_THEME) + return savedTheme && isValidTheme(savedTheme) ? savedTheme : DEFAULT_THEME +} + const getInitialTheme = (): ThemeValue => { if (typeof window === 'undefined') { return DEFAULT_THEME diff --git a/admin-ui/app/redux/features/__tests__/authSlice.test.ts b/admin-ui/app/redux/features/__tests__/authSlice.test.ts new file mode 100644 index 000000000..a4bee5c83 --- /dev/null +++ b/admin-ui/app/redux/features/__tests__/authSlice.test.ts @@ -0,0 +1,99 @@ +import reducer, { + setBackendStatus, + getOAuth2ConfigResponse, + setAuthState, + getUserInfoResponse, + getAPIAccessTokenResponse, + setApiDefaultToken, + putConfigWorker, + putConfigWorkerResponse, + createAdminUiSessionResponse, +} from '../authSlice' + +const getInitial = () => reducer(undefined, { type: '@@INIT' }) + +describe('authSlice', () => { + it('returns the initial state', () => { + const state = getInitial() + expect(state.isAuthenticated).toBe(false) + expect(state.backendStatus.active).toBe(true) + expect(state.permissions).toEqual([]) + }) + + it('setBackendStatus replaces all backend status fields', () => { + const state = reducer( + getInitial(), + setBackendStatus({ active: false, errorMessage: 'down', statusCode: 503 }), + ) + expect(state.backendStatus).toEqual({ active: false, errorMessage: 'down', statusCode: 503 }) + }) + + it('getOAuth2ConfigResponse merges config when provided', () => { + const state = reducer(getInitial(), getOAuth2ConfigResponse({ config: { issuer: 'x' } })) + expect(state.config).toMatchObject({ issuer: 'x' }) + }) + + it('getOAuth2ConfigResponse leaves config untouched when no config', () => { + const state = reducer(getInitial(), getOAuth2ConfigResponse({})) + expect(state.config).toEqual({}) + }) + + it('setAuthState toggles isAuthenticated', () => { + expect(reducer(getInitial(), setAuthState({ state: true })).isAuthenticated).toBe(true) + expect(reducer(getInitial(), setAuthState({ state: false })).isAuthenticated).toBe(false) + }) + + it('getUserInfoResponse with ujwt populates user fields and authenticates', () => { + const state = reducer( + getInitial(), + getUserInfoResponse({ + ujwt: 'jwt', + userinfo: { inum: 'inum-1' }, + idToken: 'id', + jwtToken: 'jt', + isUserInfoFetched: true, + }), + ) + expect(state.userinfo_jwt).toBe('jwt') + expect(state.userInum).toBe('inum-1') + expect(state.idToken).toBe('id') + expect(state.isUserInfoFetched).toBe(true) + expect(state.isAuthenticated).toBe(true) + }) + + it('getUserInfoResponse without ujwt only authenticates', () => { + const state = reducer(getInitial(), getUserInfoResponse({})) + expect(state.isAuthenticated).toBe(true) + expect(state.userinfo).toBeNull() + }) + + it('getAPIAccessTokenResponse sets issuer, permissions and authenticates', () => { + const state = reducer( + getInitial(), + getAPIAccessTokenResponse({ issuer: 'iss', scopes: ['a', 'b'] }), + ) + expect(state.issuer).toBe('iss') + expect(state.permissions).toEqual(['a', 'b']) + expect(state.isAuthenticated).toBe(true) + }) + + it('setApiDefaultToken only updates issuer when present', () => { + expect(reducer(getInitial(), setApiDefaultToken({ issuer: 'iss' })).issuer).toBe('iss') + expect(reducer(getInitial(), setApiDefaultToken({})).issuer).toBeNull() + }) + + it('putConfigWorker / putConfigWorkerResponse toggle loadingConfig', () => { + const loading = reducer(getInitial(), putConfigWorker({})) + expect(loading.loadingConfig).toBe(true) + expect(reducer(loading, putConfigWorkerResponse()).loadingConfig).toBe(false) + }) + + it('createAdminUiSessionResponse sets hasSession only on success', () => { + expect(reducer(getInitial(), createAdminUiSessionResponse({ success: true })).hasSession).toBe( + true, + ) + expect(reducer(getInitial(), createAdminUiSessionResponse({ success: false })).hasSession).toBe( + false, + ) + }) +}) diff --git a/admin-ui/app/redux/features/__tests__/cedarPermissionsSlice.test.ts b/admin-ui/app/redux/features/__tests__/cedarPermissionsSlice.test.ts new file mode 100644 index 000000000..4d9b03542 --- /dev/null +++ b/admin-ui/app/redux/features/__tests__/cedarPermissionsSlice.test.ts @@ -0,0 +1,47 @@ +import reducer, { + setCedarlingPermission, + setCedarlingInitialized, + setCedarlingInitializing, + setCedarFailedStatusAfterMaxTries, + setPolicyStoreBytes, +} from '../cedarPermissionsSlice' + +const getInitial = () => reducer(undefined, { type: '@@INIT' }) + +describe('cedarPermissionsSlice', () => { + it('returns the initial state', () => { + expect(getInitial()).toEqual({ + permissions: {}, + initialized: null, + isInitializing: false, + cedarFailedStatusAfterMaxTries: null, + policyStoreBytes: '', + }) + }) + + it('setCedarlingPermission stores authorization per resource id', () => { + const state = reducer( + getInitial(), + setCedarlingPermission({ resourceId: 'res-1', isAuthorized: true } as never), + ) + expect(state.permissions['res-1']).toBe(true) + }) + + it('setCedarlingInitialized sets flag and clears isInitializing', () => { + const initializing = reducer(getInitial(), setCedarlingInitializing(true)) + expect(initializing.isInitializing).toBe(true) + const done = reducer(initializing, setCedarlingInitialized(true)) + expect(done.initialized).toBe(true) + expect(done.isInitializing).toBe(false) + }) + + it('setCedarFailedStatusAfterMaxTries flips to true', () => { + expect( + reducer(getInitial(), setCedarFailedStatusAfterMaxTries()).cedarFailedStatusAfterMaxTries, + ).toBe(true) + }) + + it('setPolicyStoreBytes stores the payload', () => { + expect(reducer(getInitial(), setPolicyStoreBytes('abc')).policyStoreBytes).toBe('abc') + }) +}) diff --git a/admin-ui/app/redux/features/__tests__/initSlice.test.ts b/admin-ui/app/redux/features/__tests__/initSlice.test.ts new file mode 100644 index 000000000..2cb279f6f --- /dev/null +++ b/admin-ui/app/redux/features/__tests__/initSlice.test.ts @@ -0,0 +1,15 @@ +import { reducer } from '../initSlice' +import { handleApiTimeout } from '../initSlice' + +const getInitial = () => reducer(undefined, { type: '@@INIT' }) + +describe('initSlice', () => { + it('returns the initial state', () => { + expect(getInitial()).toEqual({ isTimeout: false }) + }) + + it('handleApiTimeout sets isTimeout from the payload', () => { + expect(reducer(getInitial(), handleApiTimeout({ isTimeout: true })).isTimeout).toBe(true) + expect(reducer(getInitial(), handleApiTimeout({ isTimeout: false })).isTimeout).toBe(false) + }) +}) diff --git a/admin-ui/app/redux/features/__tests__/logoutSlice.test.ts b/admin-ui/app/redux/features/__tests__/logoutSlice.test.ts new file mode 100644 index 000000000..022f3f7d1 --- /dev/null +++ b/admin-ui/app/redux/features/__tests__/logoutSlice.test.ts @@ -0,0 +1,36 @@ +import reducer, { logoutUser } from '../logoutSlice' +import { STORAGE_KEYS, DEFAULT_LANG } from '@/constants' +import { DEFAULT_THEME } from '@/context/theme/constants' + +describe('logoutSlice', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('clears storage and resets theme and language defaults', () => { + window.localStorage.setItem('someToken', 'abc') + window.localStorage.setItem(STORAGE_KEYS.INIT_THEME, 'light') + + reducer({}, logoutUser()) + + expect(window.localStorage.getItem('someToken')).toBeNull() + expect(window.localStorage.getItem(STORAGE_KEYS.INIT_THEME)).toBe(DEFAULT_THEME) + expect(window.localStorage.getItem(STORAGE_KEYS.INIT_LANG)).toBe(DEFAULT_LANG) + }) + + it('preserves a real user config across logout', () => { + window.localStorage.setItem(STORAGE_KEYS.USER_CONFIG, 'cfg') + + reducer({}, logoutUser()) + + expect(window.localStorage.getItem(STORAGE_KEYS.USER_CONFIG)).toBe('cfg') + }) + + it('does not restore a literal "null" user config', () => { + window.localStorage.setItem(STORAGE_KEYS.USER_CONFIG, 'null') + + reducer({}, logoutUser()) + + expect(window.localStorage.getItem(STORAGE_KEYS.USER_CONFIG)).toBeNull() + }) +}) diff --git a/admin-ui/app/redux/features/__tests__/sessionSlice.test.ts b/admin-ui/app/redux/features/__tests__/sessionSlice.test.ts new file mode 100644 index 000000000..95fc22131 --- /dev/null +++ b/admin-ui/app/redux/features/__tests__/sessionSlice.test.ts @@ -0,0 +1,19 @@ +import reducer, { auditLogoutLogs, auditLogoutLogsResponse } from '../sessionSlice' + +const getInitial = () => reducer(undefined, { type: '@@INIT' }) + +describe('sessionSlice', () => { + it('returns the initial state', () => { + expect(getInitial()).toEqual({ logoutAuditSucceeded: null }) + }) + + it('auditLogoutLogs resets the audit result to null', () => { + const state = reducer({ logoutAuditSucceeded: true }, auditLogoutLogs({ message: 'm' })) + expect(state.logoutAuditSucceeded).toBeNull() + }) + + it('auditLogoutLogsResponse stores the boolean result', () => { + expect(reducer(getInitial(), auditLogoutLogsResponse(true)).logoutAuditSucceeded).toBe(true) + expect(reducer(getInitial(), auditLogoutLogsResponse(false)).logoutAuditSucceeded).toBe(false) + }) +}) diff --git a/admin-ui/app/redux/features/__tests__/toastSlice.test.ts b/admin-ui/app/redux/features/__tests__/toastSlice.test.ts new file mode 100644 index 000000000..fa1118ff6 --- /dev/null +++ b/admin-ui/app/redux/features/__tests__/toastSlice.test.ts @@ -0,0 +1,46 @@ +import reducer, { updateToast } from '../toastSlice' + +const getInitial = () => reducer(undefined, { type: '@@INIT' }) + +describe('toastSlice', () => { + it('returns the initial state', () => { + expect(getInitial()).toEqual({ + showToast: false, + message: '', + type: 'success', + onCloseRedirectUrl: '', + }) + }) + + it('updateToast action creator builds the toast/updateToast action with defaults', () => { + expect(updateToast()).toEqual({ + type: 'toast/updateToast', + payload: { showToast: false, message: '', type: 'success', onCloseRedirectUrl: '' }, + }) + }) + + it('updateToast action creator forwards provided values', () => { + expect(updateToast(true, 'error', 'oops', '/home')).toEqual({ + type: 'toast/updateToast', + payload: { showToast: true, message: 'oops', type: 'error', onCloseRedirectUrl: '/home' }, + }) + }) + + it('reducer applies an updateToast action to state', () => { + const state = reducer(getInitial(), updateToast(true, 'warning', 'careful')) + expect(state).toEqual({ + showToast: true, + type: 'warning', + message: 'careful', + onCloseRedirectUrl: '', + }) + }) + + it('reducer defaults onCloseRedirectUrl to empty string when omitted', () => { + const state = reducer(getInitial(), { + type: 'toast/updateToast', + payload: { showToast: true, type: 'info', message: 'hi' }, + }) + expect(state.onCloseRedirectUrl).toBe('') + }) +}) diff --git a/admin-ui/app/redux/listeners/__tests__/sessionListener.test.ts b/admin-ui/app/redux/listeners/__tests__/sessionListener.test.ts new file mode 100644 index 000000000..2b0cbed1e --- /dev/null +++ b/admin-ui/app/redux/listeners/__tests__/sessionListener.test.ts @@ -0,0 +1,87 @@ +import { configureStore } from '@reduxjs/toolkit' +import { listenerMiddleware } from '../index' +import sessionReducer, { auditLogoutLogs } from '../../features/sessionSlice' +import authReducer from '../../features/authSlice' +import { + postUserAction, + fetchApiTokenWithDefaultScopes, + deleteAdminUiSession, +} from '../../api/backend-api' +import { isFourZeroThreeError } from 'Utils/TokenController' + +jest.mock('../../api/backend-api') +jest.mock('Utils/TokenController', () => ({ + addAdditionalData: jest.fn(), + isFourZeroThreeError: jest.fn(() => false), +})) + +import '../sessionListener' + +const mockedPostUserAction = postUserAction as jest.MockedFunction +const mockedFetchToken = fetchApiTokenWithDefaultScopes as jest.MockedFunction< + typeof fetchApiTokenWithDefaultScopes +> +const mockedDeleteSession = deleteAdminUiSession as jest.MockedFunction +const mockedIs403 = isFourZeroThreeError as jest.MockedFunction + +const buildStore = () => + configureStore({ + reducer: { authReducer, logoutAuditReducer: sessionReducer }, + middleware: (getDefault) => getDefault().prepend(listenerMiddleware.middleware), + }) + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('sessionListener - auditLogoutLogs', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedIs403.mockReturnValue(false) + }) + + it('dispatches a successful audit result on a 2xx response', async () => { + mockedPostUserAction.mockResolvedValue({ status: 200 }) + const store = buildStore() + + store.dispatch(auditLogoutLogs({ message: 'logout' })) + await flush() + + expect(mockedPostUserAction).toHaveBeenCalled() + expect(store.getState().logoutAuditReducer.logoutAuditSucceeded).toBe(true) + }) + + it('dispatches a failed audit result on a non-2xx response', async () => { + mockedPostUserAction.mockResolvedValue({ status: 500 }) + const store = buildStore() + + store.dispatch(auditLogoutLogs({ message: 'logout' })) + await flush() + + expect(store.getState().logoutAuditReducer.logoutAuditSucceeded).toBe(false) + }) + + it('dispatches a failed audit result on a non-403 error', async () => { + mockedPostUserAction.mockRejectedValue(new Error('boom')) + const store = buildStore() + + store.dispatch(auditLogoutLogs({ message: 'logout' })) + await flush() + + expect(store.getState().logoutAuditReducer.logoutAuditSucceeded).toBe(false) + }) + + it('runs session cleanup and skips the audit result on a 403 error', async () => { + mockedIs403.mockReturnValue(true) + mockedPostUserAction.mockRejectedValue({ response: { status: 403 } }) + mockedFetchToken.mockResolvedValue({ access_token: 'tok' }) + mockedDeleteSession.mockResolvedValue({}) + const store = buildStore() + + store.dispatch(auditLogoutLogs({ message: 'logout' })) + await flush() + + expect(mockedFetchToken).toHaveBeenCalled() + expect(mockedDeleteSession).toHaveBeenCalledWith('tok') + // the 403 branch redirects and returns without dispatching an audit result + expect(store.getState().logoutAuditReducer.logoutAuditSucceeded).toBeNull() + }) +}) diff --git a/admin-ui/app/routes/Apps/Gluu/GluuAppSidebar.tsx b/admin-ui/app/routes/Apps/Gluu/GluuAppSidebar.tsx index 6e238db7e..302e00972 100644 --- a/admin-ui/app/routes/Apps/Gluu/GluuAppSidebar.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuAppSidebar.tsx @@ -7,8 +7,6 @@ import { ThemeContext } from 'Context/theme/themeContext' import { CachedIcon } from '@/components/icons' import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' import styles from './styles/GluuAppSidebar.style' -import { MenuContext } from '../../../components/SidebarMenu/MenuContext' -import type { SidebarMenuContext } from '../../../components/SidebarMenu/MenuContext' import { HomeIcon, @@ -138,47 +136,40 @@ const GluuAppSidebar = (): JSX.Element => {
- - {(ctx: SidebarMenuContext) => - pluginMenus.map((plugin, key) => ( - - {hasChildren(plugin) && - plugin.children!.map((item, idx) => ( - - {hasChildren(item) && - item.children!.map((sub, id) => ( - - ))} - - ))} - - )) - } - + {pluginMenus.map((plugin, key) => ( + + {hasChildren(plugin) && + plugin.children!.map((item, idx) => ( + + {hasChildren(item) && + item.children!.map((sub, id) => ( + + ))} + + ))} + + ))}
) : (
diff --git a/admin-ui/app/routes/Apps/Gluu/GluuLoader.tsx b/admin-ui/app/routes/Apps/Gluu/GluuLoader.tsx index 2538964b3..4cf61b84e 100644 --- a/admin-ui/app/routes/Apps/Gluu/GluuLoader.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuLoader.tsx @@ -1,7 +1,7 @@ import React, { memo, use } from 'react' import { GluuSpinner } from '@/components/GluuSpinner' -import { ThemeContext } from '@/context/theme/themeContext' -import { THEME_DARK, DEFAULT_THEME } from '@/context/theme/constants' +import { ThemeContext, getStoredTheme } from '@/context/theme/themeContext' +import { THEME_DARK } from '@/context/theme/constants' import { useStyles } from './GluuLoader.style' interface GluuLoaderProps { @@ -11,7 +11,7 @@ interface GluuLoaderProps { const GluuLoader: React.FC = memo(({ blocking, children }) => { const themeContext = use(ThemeContext) - const currentTheme = themeContext?.state.theme || DEFAULT_THEME + const currentTheme = themeContext?.state.theme || getStoredTheme() const isDark = currentTheme === THEME_DARK const { classes } = useStyles({ isDark }) diff --git a/admin-ui/app/utils/__tests__/TokenController.test.ts b/admin-ui/app/utils/__tests__/TokenController.test.ts new file mode 100644 index 000000000..c107824f1 --- /dev/null +++ b/admin-ui/app/utils/__tests__/TokenController.test.ts @@ -0,0 +1,75 @@ +import { isFourZeroThreeError, saveIssuer, getIssuer, addAdditionalData } from '../TokenController' +import { STORAGE_KEYS } from '@/constants' +import type { AuditRecord } from 'Redux/types/audit' + +describe('TokenController', () => { + describe('isFourZeroThreeError', () => { + it('detects an axios-shaped 403 error', () => { + expect(isFourZeroThreeError({ response: { status: 403 } } as never)).toBe(true) + }) + + it('detects a direct status 403 error', () => { + expect(isFourZeroThreeError({ status: 403 } as never)).toBe(true) + }) + + it('returns false for other statuses and non-objects', () => { + expect(isFourZeroThreeError({ response: { status: 500 } } as never)).toBe(false) + expect(isFourZeroThreeError(undefined)).toBe(false) + expect(isFourZeroThreeError('boom' as never)).toBe(false) + }) + }) + + describe('issuer storage', () => { + beforeEach(() => window.localStorage.clear()) + + it('round-trips the issuer through storage', () => { + saveIssuer('https://issuer.example.com') + expect(getIssuer()).toBe('https://issuer.example.com') + expect(window.localStorage.getItem(STORAGE_KEYS.ISSUER)).toBe('https://issuer.example.com') + }) + + it('returns null when no issuer is stored', () => { + expect(getIssuer()).toBeNull() + }) + }) + + describe('addAdditionalData', () => { + it('sets action, resource and a date', () => { + const audit = {} as AuditRecord + addAdditionalData(audit, 'CREATE', '/api/v1/users') + expect(audit.action).toBe('CREATE') + expect(audit.resource).toBe('/api/v1/users') + expect(audit.date).toBeInstanceOf(Date) + }) + + it('resolves the message from nested action_message first', () => { + const audit = {} as AuditRecord + addAdditionalData(audit, 'CREATE', '/r', { + action: { action_message: 'nested' }, + message: 'flat', + } as never) + expect(audit.message).toBe('nested') + }) + + it('lifts modifiedFields and performedOn out of action_data', () => { + const audit = {} as AuditRecord + addAdditionalData(audit, 'CREATE', '/r', { + action: { action_data: { modifiedFields: { a: 1 }, performedOn: 'x' } }, + } as never) + expect(audit.modifiedFields).toEqual({ a: 1 }) + expect(audit.performedOn).toBe('x') + }) + + it('omits the payload when omitPayload is set', () => { + const audit = {} as AuditRecord + addAdditionalData(audit, 'CREATE', '/r', { omitPayload: true, foo: 'bar' } as never) + expect(audit.payload).toBeUndefined() + }) + + it('assigns the sanitized payload when not omitted', () => { + const audit = {} as AuditRecord + addAdditionalData(audit, 'CREATE', '/r', { foo: 'bar' } as never) + expect(audit.payload).toMatchObject({ foo: 'bar' }) + }) + }) +}) diff --git a/admin-ui/plugins/admin/components/Assets/__tests__/AssetForm.test.tsx b/admin-ui/plugins/admin/components/Assets/__tests__/AssetForm.test.tsx new file mode 100644 index 000000000..35f2aa159 --- /dev/null +++ b/admin-ui/plugins/admin/components/Assets/__tests__/AssetForm.test.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { Provider } from 'react-redux' +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import AssetForm from '../AssetForm' + +jest.mock('@/cedarling', () => ({ + useCedarling: () => ({ + hasCedarReadPermission: () => true, + hasCedarWritePermission: () => true, + hasCedarDeletePermission: () => true, + authorizeHelper: jest.fn(), + isLoading: false, + error: null, + }), +})) +jest.mock('JansConfigApi', () => ({ + useGetAssetByInum: () => ({ data: { entries: [] }, isLoading: false }), + useGetWebhooksByFeatureId: () => ({ data: [], isFetching: false, isFetched: true }), +})) +jest.mock('Plugins/admin/components/Assets/hooks', () => ({ + useAssetServices: () => ({ data: ['service1', 'service2'], isLoading: false }), + useCreateAssetWithAudit: () => ({ createAsset: jest.fn(), isLoading: false }), + useUpdateAssetWithAudit: () => ({ updateAsset: jest.fn(), isLoading: false }), +})) +jest.mock('@/helpers/navigation', () => ({ + useAppNavigation: () => ({ navigateBack: jest.fn() }), + ROUTES: { ASSETS_LIST: '/assets' }, +})) + +const buildStore = () => + configureStore({ + reducer: combineReducers({ + authReducer: (state = { config: { clientId: '' }, location: { IPv4: '' }, userinfo: null }) => + state, + webhookReducer: (state = { webhookModal: false, triggerWebhookInProgress: false }) => state, + }), + }) + +const renderForm = () => + render( + + + + + + + , + ) + +describe('AssetForm', () => { + it('renders the asset form fields', () => { + renderForm() + expect(document.querySelector('input[name="fileName"]')).toBeInTheDocument() + expect(document.querySelector('select[name="service"], [name="service"]')).toBeInTheDocument() + }) + + it('disables the apply action while the form is pristine', () => { + renderForm() + const apply = screen.getByRole('button', { name: /apply/i }) + expect(apply).toBeDisabled() + }) +}) diff --git a/admin-ui/plugins/admin/components/Cedarling/__tests__/PolicyStoreUploadConfirmDialog.test.tsx b/admin-ui/plugins/admin/components/Cedarling/__tests__/PolicyStoreUploadConfirmDialog.test.tsx new file mode 100644 index 000000000..66cd20728 --- /dev/null +++ b/admin-ui/plugins/admin/components/Cedarling/__tests__/PolicyStoreUploadConfirmDialog.test.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { render, screen, fireEvent, within } from '@testing-library/react' +import PolicyStoreUploadConfirmDialog from '../PolicyStoreUploadConfirmDialog' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' + +const renderDialog = ( + props: Partial> = {}, +) => + render( + , + { wrapper: AppTestWrapper }, + ) + +describe('PolicyStoreUploadConfirmDialog', () => { + it('renders nothing when closed', () => { + const { container } = render( + , + { wrapper: AppTestWrapper }, + ) + expect(container).toBeEmptyDOMElement() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('renders a modal dialog when open', () => { + renderDialog() + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(dialog).toHaveAttribute('aria-labelledby', 'confirm-upload-title') + }) + + it('calls onConfirm when the confirm button is clicked', () => { + const onConfirm = jest.fn() + renderDialog({ onConfirm }) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /yes/i })) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when the close button is clicked', () => { + const onClose = jest.fn() + renderDialog({ onClose }) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /close/i })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when Escape is pressed inside the dialog', () => { + const onClose = jest.fn() + renderDialog({ onClose }) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx b/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx new file mode 100644 index 000000000..514022d81 --- /dev/null +++ b/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import DateRangeSelector from '../DateRangeSelector' +import dayjs from 'dayjs' +import { DATE_PRESETS } from '../../constants' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' + +const baseProps = { + startDate: dayjs('2024-01-01'), + endDate: dayjs('2024-03-01'), + selectedPreset: 3, + onStartDateChange: jest.fn(), + onEndDateChange: jest.fn(), + onPresetSelect: jest.fn(), + onApply: jest.fn(), + isLoading: false, +} + +const renderSelector = (props: Partial = {}) => + render(, { wrapper: AppTestWrapper }) + +describe('DateRangeSelector', () => { + beforeEach(() => jest.clearAllMocks()) + + it('renders a button for every preset', () => { + renderSelector() + expect(screen.getByRole('button', { name: '3 Months' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '6 Months' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '1 Year' })).toBeInTheDocument() + }) + + it('calls onPresetSelect with the preset months when a preset is clicked', () => { + const onPresetSelect = jest.fn() + renderSelector({ onPresetSelect }) + fireEvent.click(screen.getByRole('button', { name: '6 Months' })) + expect(onPresetSelect).toHaveBeenCalledWith(DATE_PRESETS[1].months) + }) + + it('calls onApply when the view button is clicked', () => { + const onApply = jest.fn() + renderSelector({ onApply }) + fireEvent.click(screen.getByRole('button', { name: /view/i })) + expect(onApply).toHaveBeenCalledTimes(1) + }) + + it('disables the view button while loading', () => { + renderSelector({ isLoading: true }) + expect(screen.getByRole('button', { name: /view/i })).toBeDisabled() + }) +}) diff --git a/admin-ui/plugins/admin/components/Mapping/__tests__/RolePermissionCard.test.tsx b/admin-ui/plugins/admin/components/Mapping/__tests__/RolePermissionCard.test.tsx new file mode 100644 index 000000000..ebc7ab8df --- /dev/null +++ b/admin-ui/plugins/admin/components/Mapping/__tests__/RolePermissionCard.test.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import RolePermissionCard from '../RolePermissionCard' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' + +const candidate = { role: 'admin', permissions: ['users-read', 'users-write'] } +const allPermissions = ['users-read', 'users-write', 'clients-read'] + +const renderCard = (props: Partial> = {}) => + render( + , + { wrapper: AppTestWrapper }, + ) + +describe('RolePermissionCard', () => { + it('renders the role title and starts collapsed', () => { + renderCard() + const header = screen.getByRole('button', { name: /admin/i }) + expect(screen.getByText('admin')).toBeInTheDocument() + expect(header).toHaveAttribute('aria-expanded', 'false') + }) + + it('expands to reveal the assigned permissions on click', () => { + renderCard() + const header = screen.getByRole('button', { name: /admin/i }) + fireEvent.click(header) + expect(header).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('checkbox', { name: /users-read/i })).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: /users-write/i })).toBeInTheDocument() + }) + + it('only shows permissions assigned to the role', () => { + renderCard() + fireEvent.click(screen.getByRole('button', { name: /admin/i })) + expect(screen.queryByRole('checkbox', { name: /clients-read/i })).not.toBeInTheDocument() + }) + + it('toggles on keyboard activation', () => { + renderCard() + const header = screen.getByRole('button', { name: /admin/i }) + fireEvent.keyDown(header, { key: 'Enter' }) + expect(header).toHaveAttribute('aria-expanded', 'true') + }) +}) diff --git a/admin-ui/plugins/admin/redux/features/__tests__/WebhookSlice.test.ts b/admin-ui/plugins/admin/redux/features/__tests__/WebhookSlice.test.ts new file mode 100644 index 000000000..7b378f2cf --- /dev/null +++ b/admin-ui/plugins/admin/redux/features/__tests__/WebhookSlice.test.ts @@ -0,0 +1,53 @@ +import { + reducer, + setWebhookModal, + triggerWebhook, + completeTriggerWebhook, + setWebhookTriggerResults, + setFeatureToTrigger, + setShowWebhookExecutionDialog, +} from '../WebhookSlice' +import type { WebhookTriggerResponseItem } from '../../types' + +const getInitial = () => reducer(undefined, { type: '@@INIT' }) + +describe('WebhookSlice', () => { + it('returns the initial state', () => { + expect(getInitial()).toEqual({ + webhookModal: false, + triggerWebhookInProgress: false, + webhookTriggerResults: [], + featureToTrigger: '', + showWebhookExecutionDialog: false, + }) + }) + + it('setWebhookModal toggles the modal flag', () => { + expect(reducer(getInitial(), setWebhookModal(true)).webhookModal).toBe(true) + }) + + it('triggerWebhook / completeTriggerWebhook toggle the in-progress flag', () => { + const inProgress = reducer(getInitial(), triggerWebhook({})) + expect(inProgress.triggerWebhookInProgress).toBe(true) + expect(reducer(inProgress, completeTriggerWebhook()).triggerWebhookInProgress).toBe(false) + }) + + it('setWebhookTriggerResults stores the results array', () => { + const results: WebhookTriggerResponseItem[] = [ + { success: true, responseObject: { webhookId: 'w1' } }, + ] + expect(reducer(getInitial(), setWebhookTriggerResults(results)).webhookTriggerResults).toEqual( + results, + ) + }) + + it('setFeatureToTrigger stores the feature name', () => { + expect(reducer(getInitial(), setFeatureToTrigger('clients')).featureToTrigger).toBe('clients') + }) + + it('setShowWebhookExecutionDialog toggles the dialog flag', () => { + expect( + reducer(getInitial(), setShowWebhookExecutionDialog(true)).showWebhookExecutionDialog, + ).toBe(true) + }) +}) diff --git a/admin-ui/plugins/admin/redux/listeners/__tests__/webhookListener.test.ts b/admin-ui/plugins/admin/redux/listeners/__tests__/webhookListener.test.ts new file mode 100644 index 000000000..521a5fa85 --- /dev/null +++ b/admin-ui/plugins/admin/redux/listeners/__tests__/webhookListener.test.ts @@ -0,0 +1,82 @@ +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit' +import { setupWebhookListener } from '../webhookListener' +import { reducer as webhookReducer, triggerWebhook } from '../../features/WebhookSlice' +import authReducer from 'Redux/features/authSlice' +import { customInstance } from 'Orval' +import { postUserAction } from 'Redux/api/backend-api' +import { webhookOutputObject } from 'Plugins/admin/helper/utils' +import type { RootState } from '@/redux/types' +import type { AppDispatch } from '@/redux/hooks' + +jest.mock('Orval', () => ({ customInstance: jest.fn() })) +jest.mock('Redux/api/backend-api') +jest.mock('Utils/TokenController', () => ({ addAdditionalData: jest.fn() })) +jest.mock('Plugins/admin/helper/utils', () => ({ webhookOutputObject: jest.fn() })) + +const mockedCustomInstance = customInstance as jest.MockedFunction +const mockedPostUserAction = postUserAction as jest.MockedFunction +const mockedWebhookOutputObject = webhookOutputObject as jest.MockedFunction< + typeof webhookOutputObject +> + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +const buildStore = () => { + const listenerMiddleware = createListenerMiddleware() + setupWebhookListener(listenerMiddleware.startListening.withTypes()) + return configureStore({ + reducer: { webhookReducer, authReducer }, + middleware: (getDefault) => getDefault().prepend(listenerMiddleware.middleware), + }) +} + +describe('webhookListener - triggerWebhook', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedPostUserAction.mockResolvedValue({} as never) + }) + + it('shows the execution dialog and stores enriched results on success', async () => { + mockedCustomInstance + .mockResolvedValueOnce([{ jansEnabled: true, inum: 'w1' }] as never) + .mockResolvedValueOnce([{ responseObject: { webhookId: 'w1' } }] as never) + mockedWebhookOutputObject.mockReturnValue([ + { webhookId: 'w1', shortcodeValueMap: {}, url: 'http://hook' }, + ] as never) + + const store = buildStore() + store.dispatch(triggerWebhook({ feature: 'clients', createdFeatureValue: {} })) + await flush() + + const state = store.getState().webhookReducer + expect(state.showWebhookExecutionDialog).toBe(true) + expect(state.webhookTriggerResults).toHaveLength(1) + expect(state.webhookTriggerResults[0]).toEqual(expect.objectContaining({ url: 'http://hook' })) + expect(state.triggerWebhookInProgress).toBe(false) + }) + + it('resets state and skips the dialog when no enabled webhooks exist', async () => { + mockedCustomInstance.mockResolvedValueOnce([{ jansEnabled: false }] as never) + + const store = buildStore() + store.dispatch(triggerWebhook({ feature: 'clients', createdFeatureValue: {} })) + await flush() + + const state = store.getState().webhookReducer + expect(state.showWebhookExecutionDialog).toBe(false) + expect(state.webhookTriggerResults).toEqual([]) + expect(mockedWebhookOutputObject).not.toHaveBeenCalled() + }) + + it('clears the in-progress flag and closes the modal on error', async () => { + mockedCustomInstance.mockRejectedValueOnce(new Error('network')) + + const store = buildStore() + store.dispatch(triggerWebhook({ feature: 'clients', createdFeatureValue: {} })) + await flush() + + const state = store.getState().webhookReducer + expect(state.triggerWebhookInProgress).toBe(false) + expect(state.webhookModal).toBe(false) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/AuthServerProperties/components/__tests__/JsonPropertyBuilder.test.tsx b/admin-ui/plugins/auth-server/components/AuthServerProperties/components/__tests__/JsonPropertyBuilder.test.tsx new file mode 100644 index 000000000..b384d7f45 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/AuthServerProperties/components/__tests__/JsonPropertyBuilder.test.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import JsonPropertyBuilder from '../JsonPropertyBuilder' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' + +const renderBuilder = (props: Partial> = {}) => + render( + , + { wrapper: AppTestWrapper }, + ) + +describe('JsonPropertyBuilder', () => { + it('renders a text input for a string property with its current value', () => { + renderBuilder() + const input = screen.getByDisplayValue('hello') + expect(input).toBeInTheDocument() + }) + + it('emits a replace patch at the property path on change', () => { + const handler = jest.fn() + renderBuilder({ handler }) + fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'world' } }) + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ op: 'replace', path: '/myField', value: 'world' }), + ) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/ConfigApiProperties/components/__tests__/JsonPropertyBuilderConfigApi.test.tsx b/admin-ui/plugins/auth-server/components/ConfigApiProperties/components/__tests__/JsonPropertyBuilderConfigApi.test.tsx new file mode 100644 index 000000000..5a465abab --- /dev/null +++ b/admin-ui/plugins/auth-server/components/ConfigApiProperties/components/__tests__/JsonPropertyBuilderConfigApi.test.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import JsonPropertyBuilderConfigApi from '../JsonPropertyBuilderConfigApi' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' + +const renderBuilder = ( + props: Partial> = {}, +) => + render( + , + { wrapper: AppTestWrapper }, + ) + +describe('JsonPropertyBuilderConfigApi', () => { + it('renders a text input for a string property with its current value', () => { + renderBuilder() + expect(screen.getByDisplayValue('hello')).toBeInTheDocument() + }) + + it('emits a replace patch at the property path on change', () => { + const handler = jest.fn() + renderBuilder({ handler }) + fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'world' } }) + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ op: 'replace', path: '/myField', value: 'world' }), + ) + }) + + it('renders the input as disabled when the disabled prop is set', () => { + renderBuilder({ disabled: true }) + expect(screen.getByDisplayValue('hello')).toBeDisabled() + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Ssa/components/SsaForm.tsx b/admin-ui/plugins/auth-server/components/Ssa/components/SsaForm.tsx index 466635cce..ea8bf48cd 100644 --- a/admin-ui/plugins/auth-server/components/Ssa/components/SsaForm.tsx +++ b/admin-ui/plugins/auth-server/components/Ssa/components/SsaForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react' +import React, { useState, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useFormik, type FormikProps } from 'formik' import type { JsonValue } from 'Routes/Apps/Gluu/types/common' @@ -56,7 +56,14 @@ const SsaForm: React.FC = ({ const [modifiedFields, setModifiedFields] = useState({}) const [pendingPayload, setPendingPayload] = useState(null) const [formHeight, setFormHeight] = useState(0) - const formContentRef = useRef(null) + const formContentRef = useCallback((node: HTMLDivElement | null) => { + if (!node) return + const observer = new ResizeObserver(([entry]) => { + setFormHeight(entry.contentRect.height) + }) + observer.observe(node) + return () => observer.disconnect() + }, []) const formik = useFormik({ initialValues: getSsaInitialValues(), @@ -70,15 +77,6 @@ const SsaForm: React.FC = ({ }, }) - useEffect(() => { - if (!formContentRef.current) return - const observer = new ResizeObserver(([entry]) => { - setFormHeight(entry.contentRect.height) - }) - observer.observe(formContentRef.current) - return () => observer.disconnect() - }, []) - const handleNavigateBack = useCallback(() => { navigateBack(ROUTES.AUTH_SERVER_SSA_LIST) }, [navigateBack]) diff --git a/admin-ui/plugins/auth-server/redux/features/__tests__/scopeSlice.test.ts b/admin-ui/plugins/auth-server/redux/features/__tests__/scopeSlice.test.ts new file mode 100644 index 000000000..3f4e366e5 --- /dev/null +++ b/admin-ui/plugins/auth-server/redux/features/__tests__/scopeSlice.test.ts @@ -0,0 +1,17 @@ +import { reducer, setClientSelectedScopes } from '../scopeSlice' +import type { ScopeItem } from 'Redux/types' + +const getInitial = () => reducer(undefined, { type: '@@INIT' }) + +describe('scopeSlice', () => { + it('returns the initial state', () => { + expect(getInitial()).toEqual({ selectedClientScopes: [] }) + }) + + it('setClientSelectedScopes replaces the selected scopes', () => { + const scopes: ScopeItem[] = [{ inum: 's1' }, { inum: 's2' }] + expect(reducer(getInitial(), setClientSelectedScopes(scopes)).selectedClientScopes).toEqual( + scopes, + ) + }) +}) diff --git a/admin-ui/plugins/auth-server/utils/__tests__/sessionExpiredRedirect.test.ts b/admin-ui/plugins/auth-server/utils/__tests__/sessionExpiredRedirect.test.ts new file mode 100644 index 000000000..7b208e910 --- /dev/null +++ b/admin-ui/plugins/auth-server/utils/__tests__/sessionExpiredRedirect.test.ts @@ -0,0 +1,44 @@ +import { redirectSessionExpired } from '../sessionExpiredRedirect' +import store from '@/redux/store' +import { auditLogoutLogs } from '@/redux/features/sessionSlice' +import { fetchApiTokenWithDefaultScopes, deleteAdminUiSession } from '@/redux/api/backend-api' +import { SESSION_EXPIRED } from '@/audit/messages' + +jest.mock('@/redux/api/backend-api') + +const mockedFetchToken = fetchApiTokenWithDefaultScopes as jest.MockedFunction< + typeof fetchApiTokenWithDefaultScopes +> +const mockedDeleteSession = deleteAdminUiSession as jest.MockedFunction + +describe('redirectSessionExpired', () => { + let dispatchSpy: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + dispatchSpy = jest.spyOn(store, 'dispatch') + }) + + afterEach(() => dispatchSpy.mockRestore()) + + it('dispatches the audit log and cleans up the session', async () => { + mockedFetchToken.mockResolvedValue({ access_token: 'tok' }) + mockedDeleteSession.mockResolvedValue({}) + + await redirectSessionExpired() + + expect(dispatchSpy).toHaveBeenCalledWith(auditLogoutLogs({ message: SESSION_EXPIRED })) + expect(mockedFetchToken).toHaveBeenCalled() + expect(mockedDeleteSession).toHaveBeenCalledWith('tok') + }) + + it('dispatches with a custom message and swallows a session cleanup failure', async () => { + mockedFetchToken.mockResolvedValue({ access_token: 'tok' }) + mockedDeleteSession.mockRejectedValue(new Error('delete failed')) + + await expect(redirectSessionExpired('custom message')).resolves.toBeUndefined() + + expect(dispatchSpy).toHaveBeenCalledWith(auditLogoutLogs({ message: 'custom message' })) + expect(mockedDeleteSession).toHaveBeenCalledWith('tok') + }) +}) diff --git a/admin-ui/plugins/fido/components/Metrics/components/PasskeyAdoptionChart.tsx b/admin-ui/plugins/fido/components/Metrics/components/PasskeyAdoptionChart.tsx index 13a412422..add81bc67 100644 --- a/admin-ui/plugins/fido/components/Metrics/components/PasskeyAdoptionChart.tsx +++ b/admin-ui/plugins/fido/components/Metrics/components/PasskeyAdoptionChart.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState, useEffect } from 'react' +import React, { useMemo, useState, useEffect, useCallback } from 'react' import { Card, CardBody } from 'Components' import { BarChart, @@ -95,17 +95,15 @@ const PasskeyAdoptionChart: React.FC = ({ dateRange } ) // Measure the chart container to compute bar positions for the SVG overlay - const containerRef = useRef(null) const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) - useEffect(() => { - const el = containerRef.current - if (!el) return + const containerRef = useCallback((node: HTMLDivElement | null) => { + if (!node) return const ro = new ResizeObserver((entries) => { const { width, height } = entries[0]!.contentRect setContainerSize({ width, height }) }) - ro.observe(el) + ro.observe(node) return () => ro.disconnect() }, []) diff --git a/admin-ui/plugins/user-management/components/User2FADevicesModal.tsx b/admin-ui/plugins/user-management/components/User2FADevicesModal.tsx index 0662f3e5b..63e2a3e7f 100644 --- a/admin-ui/plugins/user-management/components/User2FADevicesModal.tsx +++ b/admin-ui/plugins/user-management/components/User2FADevicesModal.tsx @@ -185,6 +185,9 @@ const User2FADevicesModal = ({ isOpen, onClose, userDetails, theme }: User2FADev }) }, [processedFidoDetails, otpDevicesList]) + const [page, setPage] = useState(0) + const [rowsPerPage, setRowsPerPage] = useState(10) + // Reset pagination when user or data changes useEffect(() => { setPage(0) @@ -234,9 +237,6 @@ const User2FADevicesModal = ({ isOpen, onClose, userDetails, theme }: User2FADev [userDetails, deleteFido2Mutation, updateUserData], ) - const [page, setPage] = useState(0) - const [rowsPerPage, setRowsPerPage] = useState(10) - const columns: ColumnDef[] = useMemo( () => [ { key: 'nickName', label: t('fields.nickName') }, diff --git a/admin-ui/plugins/user-management/components/UserForm.tsx b/admin-ui/plugins/user-management/components/UserForm.tsx index 6fdc629e5..5b6f2898c 100644 --- a/admin-ui/plugins/user-management/components/UserForm.tsx +++ b/admin-ui/plugins/user-management/components/UserForm.tsx @@ -74,17 +74,15 @@ const UserForm = ({ ) const initializedRef = useRef(null) - const formContentRef = useRef(null) const [formHeight, setFormHeight] = useState(undefined) - useEffect(() => { - const el = formContentRef.current - if (!el) return + const formContentRef = useCallback((node: HTMLDivElement | null) => { + if (!node) return const observer = new ResizeObserver((entries) => { const entry = entries[0] if (entry) setFormHeight(entry.contentRect.height) }) - observer.observe(el) + observer.observe(node) return () => observer.disconnect() }, []) const formik = useFormik({ diff --git a/admin-ui/plugins/user-management/components/__tests__/PasswordChangeModal.test.tsx b/admin-ui/plugins/user-management/components/__tests__/PasswordChangeModal.test.tsx new file mode 100644 index 000000000..91bcda464 --- /dev/null +++ b/admin-ui/plugins/user-management/components/__tests__/PasswordChangeModal.test.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { render, screen, fireEvent, within } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import PasswordChangeModal from '../PasswordChangeModal' +import toastReducer from '@/redux/features/toastSlice' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' + +jest.mock('@/cedarling', () => ({ + useCedarling: () => ({ + hasCedarReadPermission: () => true, + hasCedarWritePermission: () => true, + hasCedarDeletePermission: () => true, + authorizeHelper: jest.fn(), + isLoading: false, + error: null, + }), +})) +jest.mock('JansConfigApi', () => ({ + usePatchUserByInum: () => ({ mutateAsync: jest.fn(), isPending: false }), + useRevokeUserSession: () => ({ mutateAsync: jest.fn() }), + getGetUserQueryKey: () => ['user'], + useGetWebhooksByFeatureId: () => ({ data: [], isFetching: false, isFetched: true }), +})) +jest.mock('Orval', () => ({ + AXIOS_INSTANCE: { delete: jest.fn() }, + customInstance: jest.fn(), + installInterceptors: jest.fn(), + setApiToken: jest.fn(), +})) + +const userDetails = { inum: 'inum-1', userId: 'alice', displayName: 'Alice' } + +const renderModal = (props: Partial> = {}) => { + const store = configureStore({ reducer: { toastReducer } }) + const queryClient = new QueryClient() + return render( + + + + + + + , + ) +} + +describe('PasswordChangeModal', () => { + it('renders nothing when closed', () => { + const { container } = renderModal({ isOpen: false }) + expect(container).toBeEmptyDOMElement() + }) + + it('renders the password change dialog with both fields', () => { + renderModal() + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(document.getElementById('userPassword')).toBeInTheDocument() + expect(document.getElementById('userConfirmPassword')).toBeInTheDocument() + }) + + it('disables the submit button while the form is pristine', () => { + renderModal() + const submit = screen.getByRole('button', { name: /change password/i }) + expect(submit).toBeDisabled() + }) + + it('toggles password field visibility', () => { + renderModal() + const passwordInput = document.getElementById('userPassword') as HTMLInputElement + const group = passwordInput.closest('div')!.parentElement as HTMLElement + expect(passwordInput.type).toBe('password') + fireEvent.click(within(group).getByRole('button')) + expect(passwordInput.type).toBe('text') + }) +}) diff --git a/admin-ui/plugins/user-management/components/__tests__/User2FADevicesModal.test.tsx b/admin-ui/plugins/user-management/components/__tests__/User2FADevicesModal.test.tsx new file mode 100644 index 000000000..528915752 --- /dev/null +++ b/admin-ui/plugins/user-management/components/__tests__/User2FADevicesModal.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import User2FADevicesModal from '../User2FADevicesModal' +import toastReducer from '@/redux/features/toastSlice' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' + +jest.mock('@/cedarling', () => ({ + useCedarling: () => ({ + hasCedarReadPermission: () => true, + hasCedarWritePermission: () => true, + hasCedarDeletePermission: () => true, + authorizeHelper: jest.fn(), + isLoading: false, + error: null, + }), +})) +jest.mock('JansConfigApi', () => ({ + useGetRegistrationEntriesFido2: () => ({ + data: [], + refetch: jest.fn(), + isLoading: false, + isError: false, + error: null, + }), + useDeleteFido2Data: () => ({ mutateAsync: jest.fn(), isPending: false }), + usePutUser: () => ({ mutateAsync: jest.fn(), isPending: false }), + getGetUserQueryKey: () => ['user'], +})) + +const userDetails = { userId: 'alice', inum: 'inum-1' } + +const renderModal = (props: Partial> = {}) => + render( + + + + + + + , + ) + +describe('User2FADevicesModal', () => { + it('renders nothing when closed', () => { + renderModal({ isOpen: false }) + expect(screen.queryByText(/2FA/i)).not.toBeInTheDocument() + }) + + it('renders the 2FA details header when open', () => { + renderModal() + expect(screen.getByText(/2FA/i)).toBeInTheDocument() + }) + + it('calls onClose when the header close button is clicked', () => { + const onClose = jest.fn() + renderModal({ onClose }) + fireEvent.click(screen.getAllByRole('button', { name: /close/i })[0]) + expect(onClose).toHaveBeenCalled() + }) +})