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()
+ })
+})