diff --git a/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookAddPage.test.tsx b/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookAddPage.test.tsx
new file mode 100644
index 000000000..97c3fac8b
--- /dev/null
+++ b/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookAddPage.test.tsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import type { Store } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import { usePermission } from '@/cedarling/hooks/usePermission'
+import WebhookAddPage from 'Plugins/admin/components/Webhook/WebhookAddPage'
+
+jest.mock('Plugins/PluginReducersResolver', () => ({ __esModule: true, default: jest.fn() }))
+jest.mock('Plugins/PluginListenersResolver', () => ({ __esModule: true, default: jest.fn() }))
+
+jest.mock('@/cedarling', () => ({
+ useCedarling: jest.fn(() => ({
+ hasCedarReadPermission: jest.fn(() => true),
+ hasCedarWritePermission: jest.fn(() => true),
+ hasCedarDeletePermission: jest.fn(() => true),
+ authorizeHelper: jest.fn(),
+ isLoading: false,
+ error: null,
+ })),
+ ADMIN_UI_RESOURCES: { Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { Webhooks: [] },
+}))
+
+jest.mock('@/cedarling/utility', () => ({
+ ADMIN_UI_RESOURCES: { Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { Webhooks: [] },
+}))
+
+jest.mock('@/cedarling/hooks/usePermission', () => ({
+ usePermission: jest.fn(() => ({ canRead: true, canWrite: true, canDelete: true })),
+}))
+
+jest.mock('Plugins/admin/components/Webhook/WebhookForm', () => ({
+ __esModule: true,
+ default: () =>
Webhook Form
,
+}))
+
+const createWebhookTestStore = (): Store =>
+ configureStore({
+ reducer: combineReducers({
+ authReducer: (
+ state = {
+ permissions: [] as string[],
+ config: { clientId: '' },
+ location: { IPv4: '' },
+ userinfo: null as { name: string; inum: string } | null,
+ },
+ ) => state,
+ webhookReducer: (
+ state = {
+ featureWebhooks: [],
+ loadingWebhooks: false,
+ webhookModal: false,
+ triggerWebhookInProgress: false,
+ },
+ ) => state,
+ cedarPermissions: (state = { initialized: true }) => state,
+ noReducer: (state = {}) => state,
+ }),
+ })
+
+const createWebhookTestQueryClient = (): QueryClient =>
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const createWebhookTestWrapper = (store: Store, client: QueryClient) =>
+ function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ )
+ }
+
+const usePermissionMock = usePermission as jest.MockedFunction
+
+const renderPage = () => {
+ const queryClient = createWebhookTestQueryClient()
+ const store = createWebhookTestStore()
+ const Wrapper = createWebhookTestWrapper(store, queryClient)
+ return render(, { wrapper: Wrapper })
+}
+
+describe('WebhookAddPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ usePermissionMock.mockReturnValue({ canRead: true, canWrite: true, canDelete: true })
+ })
+
+ it('renders without crashing', () => {
+ renderPage()
+ expect(screen.getByTestId('webhook-form')).toBeInTheDocument()
+ })
+
+ it('renders the WebhookForm when the user can write', () => {
+ renderPage()
+ expect(screen.getByTestId('webhook-form')).toBeInTheDocument()
+ })
+
+ it('hides the WebhookForm when the user cannot write', () => {
+ usePermissionMock.mockReturnValue({ canRead: true, canWrite: false, canDelete: false })
+ renderPage()
+ expect(screen.queryByTestId('webhook-form')).not.toBeInTheDocument()
+ })
+})
diff --git a/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookEditPage.test.tsx b/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookEditPage.test.tsx
new file mode 100644
index 000000000..1d994e8b0
--- /dev/null
+++ b/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookEditPage.test.tsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import type { Store } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import { usePermission } from '@/cedarling/hooks/usePermission'
+import WebhookEditPage from 'Plugins/admin/components/Webhook/WebhookEditPage'
+
+jest.mock('Plugins/PluginReducersResolver', () => ({ __esModule: true, default: jest.fn() }))
+jest.mock('Plugins/PluginListenersResolver', () => ({ __esModule: true, default: jest.fn() }))
+
+jest.mock('@/cedarling', () => ({
+ useCedarling: jest.fn(() => ({
+ hasCedarReadPermission: jest.fn(() => true),
+ hasCedarWritePermission: jest.fn(() => true),
+ hasCedarDeletePermission: jest.fn(() => true),
+ authorizeHelper: jest.fn(),
+ isLoading: false,
+ error: null,
+ })),
+ ADMIN_UI_RESOURCES: { Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { Webhooks: [] },
+}))
+
+jest.mock('@/cedarling/utility', () => ({
+ ADMIN_UI_RESOURCES: { Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { Webhooks: [] },
+}))
+
+jest.mock('@/cedarling/hooks/usePermission', () => ({
+ usePermission: jest.fn(() => ({ canRead: true, canWrite: true, canDelete: true })),
+}))
+
+jest.mock('Plugins/admin/components/Webhook/WebhookForm', () => ({
+ __esModule: true,
+ default: () => Webhook Form
,
+}))
+
+const createWebhookTestStore = (): Store =>
+ configureStore({
+ reducer: combineReducers({
+ authReducer: (
+ state = {
+ permissions: [] as string[],
+ config: { clientId: '' },
+ location: { IPv4: '' },
+ userinfo: null as { name: string; inum: string } | null,
+ },
+ ) => state,
+ webhookReducer: (
+ state = {
+ featureWebhooks: [],
+ loadingWebhooks: false,
+ webhookModal: false,
+ triggerWebhookInProgress: false,
+ },
+ ) => state,
+ cedarPermissions: (state = { initialized: true }) => state,
+ noReducer: (state = {}) => state,
+ }),
+ })
+
+const createWebhookTestQueryClient = (): QueryClient =>
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const createWebhookTestWrapper = (store: Store, client: QueryClient) =>
+ function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ )
+ }
+
+const usePermissionMock = usePermission as jest.MockedFunction
+
+const renderPage = () => {
+ const queryClient = createWebhookTestQueryClient()
+ const store = createWebhookTestStore()
+ const Wrapper = createWebhookTestWrapper(store, queryClient)
+ return render(, { wrapper: Wrapper })
+}
+
+describe('WebhookEditPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ usePermissionMock.mockReturnValue({ canRead: true, canWrite: true, canDelete: true })
+ })
+
+ it('renders without crashing', () => {
+ renderPage()
+ expect(screen.getByTestId('webhook-form')).toBeInTheDocument()
+ })
+
+ it('renders the WebhookForm when the user can write', () => {
+ renderPage()
+ expect(screen.getByTestId('webhook-form')).toBeInTheDocument()
+ })
+
+ it('hides the WebhookForm when the user cannot write', () => {
+ usePermissionMock.mockReturnValue({ canRead: true, canWrite: false, canDelete: false })
+ renderPage()
+ expect(screen.queryByTestId('webhook-form')).not.toBeInTheDocument()
+ })
+})
diff --git a/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookForm.test.tsx b/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookForm.test.tsx
new file mode 100644
index 000000000..f059d46eb
--- /dev/null
+++ b/admin-ui/plugins/admin/__tests__/components/Webhook/WebhookForm.test.tsx
@@ -0,0 +1,261 @@
+import React, { act } from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import type { Store } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import type { WebhookEntry } from 'Plugins/admin/components/Webhook/types'
+import { useGetWebhook } from 'Plugins/admin/components/Webhook/hooks'
+import { useParams } from 'react-router-dom'
+import WebhookForm from 'Plugins/admin/components/Webhook/WebhookForm'
+
+jest.mock('Plugins/PluginReducersResolver', () => ({ __esModule: true, default: jest.fn() }))
+jest.mock('Plugins/PluginListenersResolver', () => ({ __esModule: true, default: jest.fn() }))
+
+jest.mock('@/cedarling', () => ({
+ useCedarling: jest.fn(() => ({
+ hasCedarReadPermission: jest.fn(() => true),
+ hasCedarWritePermission: jest.fn(() => true),
+ hasCedarDeletePermission: jest.fn(() => true),
+ authorizeHelper: jest.fn(),
+ isLoading: false,
+ error: null,
+ })),
+ ADMIN_UI_RESOURCES: { Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { Webhooks: [] },
+}))
+
+jest.mock('@/cedarling/utility', () => ({
+ ADMIN_UI_RESOURCES: { Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { Webhooks: [] },
+}))
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: jest.fn(() => ({})),
+}))
+
+jest.mock('@/helpers/navigation', () => ({
+ ...jest.requireActual('@/helpers/navigation'),
+ useAppNavigation: jest.fn(() => ({ navigateBack: jest.fn() })),
+}))
+
+const mockCreateWebhook = jest.fn()
+const mockUpdateWebhook = jest.fn()
+
+jest.mock('Plugins/admin/components/Webhook/hooks', () => ({
+ useGetWebhook: jest.fn(() => ({ webhook: undefined, isPending: false })),
+ useCreateWebhookWithAudit: jest.fn(() => ({
+ createWebhook: mockCreateWebhook,
+ isLoading: false,
+ })),
+ useUpdateWebhookWithAudit: jest.fn(() => ({
+ updateWebhook: mockUpdateWebhook,
+ isLoading: false,
+ })),
+}))
+
+jest.mock('JansConfigApi', () => {
+ const mockAllFeatures = [
+ { auiFeatureId: 'feature-1', displayName: 'Feature One' },
+ { auiFeatureId: 'feature-2', displayName: 'Feature Two' },
+ ]
+ const mockEmptyList: never[] = []
+ const mockEmptyPaged = { entries: mockEmptyList }
+ return {
+ useGetAllFeatures: jest.fn(() => ({
+ data: mockAllFeatures,
+ status: 'success',
+ isFetching: false,
+ })),
+ useGetFeaturesByWebhookId: jest.fn(() => ({
+ data: mockEmptyList,
+ status: 'success',
+ isFetching: false,
+ })),
+ useGetAllWebhooks: jest.fn(() => ({
+ data: mockEmptyPaged,
+ status: 'success',
+ isFetching: false,
+ })),
+ useGetWebhooksByFeatureId: jest.fn(() => ({
+ data: mockEmptyList,
+ isLoading: false,
+ isFetching: false,
+ isFetched: true,
+ })),
+ }
+})
+
+const createWebhookTestStore = (): Store =>
+ configureStore({
+ reducer: combineReducers({
+ authReducer: (
+ state = {
+ permissions: [] as string[],
+ config: { clientId: '' },
+ location: { IPv4: '' },
+ userinfo: null as { name: string; inum: string } | null,
+ },
+ ) => state,
+ webhookReducer: (
+ state = {
+ featureWebhooks: [],
+ loadingWebhooks: false,
+ webhookModal: false,
+ triggerWebhookInProgress: false,
+ },
+ ) => state,
+ cedarPermissions: (state = { initialized: true }) => state,
+ noReducer: (state = {}) => state,
+ }),
+ })
+
+const createWebhookTestQueryClient = (): QueryClient =>
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const createWebhookTestWrapper = (store: Store, client: QueryClient) =>
+ function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ )
+ }
+
+const useGetWebhookMock = useGetWebhook as jest.MockedFunction
+const useParamsMock = useParams as jest.MockedFunction
+
+const mockWebhook: WebhookEntry = {
+ inum: 'wh-123',
+ dn: 'inum=wh-123,ou=webhooks',
+ displayName: 'My Webhook',
+ url: 'https://example.com/hook',
+ httpMethod: 'GET',
+ description: 'A test webhook',
+ jansEnabled: true,
+ httpHeaders: [],
+ auiFeatureIds: ['feature-1'],
+}
+
+const renderForm = async () => {
+ const queryClient = createWebhookTestQueryClient()
+ const store = createWebhookTestStore()
+ const Wrapper = createWebhookTestWrapper(store, queryClient)
+ let result: ReturnType
+ await act(async () => {
+ result = render(, { wrapper: Wrapper })
+ })
+ await screen.findByTestId('displayName')
+ return result!
+}
+
+describe('WebhookForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ useParamsMock.mockReturnValue({})
+ useGetWebhookMock.mockReturnValue({
+ webhook: undefined,
+ isPending: false,
+ } as ReturnType)
+ })
+
+ describe('add mode (no id)', () => {
+ it('renders without crashing and shows the name field', async () => {
+ await renderForm()
+ expect(screen.getByTestId('displayName')).toBeInTheDocument()
+ })
+
+ it('renders an empty name field', async () => {
+ await renderForm()
+ expect(screen.getByTestId('displayName')).toHaveValue('')
+ })
+
+ it('renders an empty url field', async () => {
+ await renderForm()
+ expect(screen.getByTestId('url')).toHaveValue('')
+ })
+
+ it('renders the http method select', async () => {
+ await renderForm()
+ expect(screen.getByTestId('httpMethod')).toBeInTheDocument()
+ })
+
+ it('renders the feature select with feature options', async () => {
+ await renderForm()
+ expect(screen.getByText('Feature One')).toBeInTheDocument()
+ expect(screen.getByText('Feature Two')).toBeInTheDocument()
+ })
+
+ it('renders the footer Back, Cancel and Apply actions', async () => {
+ await renderForm()
+ expect(screen.getByText(/Back/i)).toBeInTheDocument()
+ expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/Apply/i)).toBeInTheDocument()
+ })
+
+ it('disables Cancel when the form is pristine', async () => {
+ await renderForm()
+ const cancelBtn = screen.getByText(/Cancel/i).closest('button')
+ expect(cancelBtn).toBeDisabled()
+ })
+
+ it('allows typing in the name field', async () => {
+ await renderForm()
+ const nameInput = screen.getByTestId('displayName')
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: 'New Hook' } })
+ })
+ expect(nameInput).toHaveValue('New Hook')
+ })
+
+ it('enables Cancel once the form becomes dirty', async () => {
+ await renderForm()
+ await act(async () => {
+ fireEvent.change(screen.getByTestId('url'), {
+ target: { value: 'https://changed.example.com' },
+ })
+ })
+ await waitFor(() => {
+ expect(screen.getByText(/Cancel/i).closest('button')).not.toBeDisabled()
+ })
+ })
+ })
+
+ describe('edit mode (with id)', () => {
+ beforeEach(() => {
+ useParamsMock.mockReturnValue({ id: 'wh-123' })
+ useGetWebhookMock.mockReturnValue({
+ webhook: mockWebhook,
+ isPending: false,
+ } as ReturnType)
+ })
+
+ it('populates the name field with the webhook display name', async () => {
+ await renderForm()
+ expect(screen.getByTestId('displayName')).toHaveValue('My Webhook')
+ })
+
+ it('populates the url field with the webhook url', async () => {
+ await renderForm()
+ expect(screen.getByTestId('url')).toHaveValue('https://example.com/hook')
+ })
+
+ it('populates the description field with the webhook description', async () => {
+ await renderForm()
+ expect(screen.getByTestId('description')).toHaveValue('A test webhook')
+ })
+
+ it('renders the enabled toggle as checked', async () => {
+ await renderForm()
+ const toggle = document.querySelector(
+ 'input#jansEnabled[type="checkbox"]',
+ ) as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(true)
+ })
+ })
+})
diff --git a/admin-ui/plugins/auth-server/components/Authentication/Acrs/__tests__/AcrsForm.test.tsx b/admin-ui/plugins/auth-server/components/Authentication/Acrs/__tests__/AcrsForm.test.tsx
new file mode 100644
index 000000000..9b0572d00
--- /dev/null
+++ b/admin-ui/plugins/auth-server/components/Authentication/Acrs/__tests__/AcrsForm.test.tsx
@@ -0,0 +1,130 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import {
+ createAuthenticationTestStore,
+ createAuthenticationTestWrapper,
+} from '../../__tests__/helpers/authenticationTestUtils'
+import AcrsForm from '../AcrsForm'
+import type { AuthNItem } from '../../types'
+import { AUTH_METHOD_NAMES } from '../../constants'
+
+const simplePasswordItem: AuthNItem = {
+ name: AUTH_METHOD_NAMES.SIMPLE_PASSWORD,
+ acrName: AUTH_METHOD_NAMES.SIMPLE_PASSWORD,
+ level: -1,
+ description: 'Built-in default password authentication',
+ samlACR: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
+ primaryKey: 'uid',
+ passwordAttribute: 'userPassword',
+ hashAlgorithm: 'bcrypt',
+}
+
+const ldapItem: AuthNItem = {
+ name: AUTH_METHOD_NAMES.DEFAULT_LDAP,
+ acrName: AUTH_METHOD_NAMES.DEFAULT_LDAP,
+ level: 1,
+ description: 'LDAP authentication',
+ configId: 'test-ldap',
+ bindDN: 'cn=directory manager',
+ bindPassword: 'secret',
+ maxConnections: 10,
+ primaryKey: 'uid',
+ localPrimaryKey: 'uid',
+ servers: ['localhost:1636'],
+ baseDNs: ['ou=people,o=jans'],
+ useSSL: true,
+ enabled: true,
+}
+
+const scriptItem: AuthNItem = {
+ inum: 'test-inum',
+ name: 'myAuthnScript',
+ acrName: 'test_otp',
+ isCustomScript: true,
+ level: 5,
+ description: 'Test OTP authentication script',
+ enabled: true,
+ configurationProperties: [{ key: 'k1', value: 'v1' }],
+}
+
+describe('AcrsForm', () => {
+ let Wrapper: React.ComponentType<{ children: React.ReactNode }>
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ const store = createAuthenticationTestStore()
+ Wrapper = createAuthenticationTestWrapper(store)
+ })
+
+ describe('rendering', () => {
+ it('renders without crashing for a simple password item', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Level/i)).toBeInTheDocument()
+ })
+
+ it('renders the acr field as disabled and populated', () => {
+ render(, { wrapper: Wrapper })
+ const acrInput = screen.getByDisplayValue(AUTH_METHOD_NAMES.SIMPLE_PASSWORD)
+ expect(acrInput).toBeDisabled()
+ })
+
+ it('renders footer Back, Cancel and Apply buttons', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Back/i)).toBeInTheDocument()
+ expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/Apply/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('simple password fields', () => {
+ it('renders the primary key field populated', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByDisplayValue('uid')).toBeInTheDocument()
+ })
+
+ it('renders the password attribute field populated', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByDisplayValue('userPassword')).toBeInTheDocument()
+ })
+ })
+
+ describe('ldap fields', () => {
+ it('renders ldap-specific fields when item is default ldap', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByDisplayValue('cn=directory manager')).toBeInTheDocument()
+ })
+
+ it('renders max connections value for ldap item', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByDisplayValue('10')).toBeInTheDocument()
+ })
+ })
+
+ describe('custom script properties', () => {
+ it('renders the add property button for a custom script item', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Add Property/i)).toBeInTheDocument()
+ })
+
+ it('adds a configuration property row when add button is clicked', async () => {
+ render(, { wrapper: Wrapper })
+ const initialRemove = screen.getAllByText(/Remove/i).length
+ fireEvent.click(screen.getByText(/Add Property/i))
+ await waitFor(() => {
+ expect(screen.getAllByText(/Remove/i)).toHaveLength(initialRemove + 1)
+ })
+ })
+ })
+
+ describe('footer state', () => {
+ it('disables Cancel button when the form is not dirty', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Cancel/i).closest('button')).toBeDisabled()
+ })
+
+ it('disables Apply button when the form is not dirty', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Apply/i).closest('button')).toBeDisabled()
+ })
+ })
+})
diff --git a/admin-ui/plugins/auth-server/components/ConfigApiProperties/__tests__/components/ConfigApiPropertiesPage.test.tsx b/admin-ui/plugins/auth-server/components/ConfigApiProperties/__tests__/components/ConfigApiPropertiesPage.test.tsx
new file mode 100644
index 000000000..219b44e1e
--- /dev/null
+++ b/admin-ui/plugins/auth-server/components/ConfigApiProperties/__tests__/components/ConfigApiPropertiesPage.test.tsx
@@ -0,0 +1,130 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import ConfigApiPropertiesPage from '../../components/ConfigApiPropertiesPage'
+import type { ApiAppConfiguration } from '../../types'
+
+type MockQueryResult = {
+ data: ApiAppConfiguration | undefined
+ isLoading: boolean
+ error: { message: string } | null
+ refetch: () => void
+}
+
+const mockHasCedarReadPermission = jest.fn(() => true)
+const mockUseGetConfigApiProperties = jest.fn(() => ({
+ data: undefined,
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+}))
+
+jest.mock('@/cedarling', () => ({
+ useCedarling: jest.fn(() => ({
+ hasCedarReadPermission: mockHasCedarReadPermission,
+ hasCedarWritePermission: jest.fn(() => true),
+ hasCedarDeletePermission: jest.fn(() => true),
+ authorizeHelper: jest.fn(),
+ })),
+ ADMIN_UI_RESOURCES: { ConfigApiConfiguration: 'ConfigApiConfiguration' },
+}))
+
+jest.mock('@/cedarling/utility', () => ({
+ ADMIN_UI_RESOURCES: { ConfigApiConfiguration: 'ConfigApiConfiguration' },
+ CEDAR_RESOURCE_SCOPES: { ConfigApiConfiguration: ['read', 'write'] },
+}))
+
+jest.mock('JansConfigApi', () => ({
+ useGetConfigApiProperties: () => mockUseGetConfigApiProperties(),
+ usePatchConfigApiProperties: jest.fn(() => ({
+ mutateAsync: jest.fn(),
+ isPending: false,
+ })),
+}))
+
+jest.mock('../../utils/useConfigApiActions', () => ({
+ useConfigApiActions: () => ({
+ logConfigApiUpdate: jest.fn(),
+ }),
+}))
+
+jest.mock('../../components/ConfigApiPropertiesForm', () => {
+ const MockForm = () => Mock Form
+ MockForm.displayName = 'MockConfigApiPropertiesForm'
+ return MockForm
+})
+
+const createStore = () =>
+ configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession: true, permissions: [], config: {} }) => state,
+ cedarPermissions: (state = { initialized: true }) => state,
+ noReducer: (state = {}) => state,
+ }),
+ })
+
+const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+)
+
+describe('ConfigApiPropertiesPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockHasCedarReadPermission.mockReturnValue(true)
+ mockUseGetConfigApiProperties.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ })
+ })
+
+ it('renders without crashing and shows the form when permitted', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByTestId('config-api-properties-form')).toBeInTheDocument()
+ })
+
+ it('renders the search toolbar', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Search/i)).toBeInTheDocument()
+ })
+
+ it('renders the form when configuration data is provided', () => {
+ mockUseGetConfigApiProperties.mockReturnValue({
+ data: { apiApprovedIssuer: ['issuer1'] },
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ })
+ render(, { wrapper: Wrapper })
+ expect(screen.getByTestId('config-api-properties-form')).toBeInTheDocument()
+ })
+
+ it('hides the form and shows the missing permission view when read is denied', () => {
+ mockHasCedarReadPermission.mockReturnValue(false)
+ render(, { wrapper: Wrapper })
+ expect(screen.queryByTestId('config-api-properties-form')).not.toBeInTheDocument()
+ expect(screen.getByTestId('MISSING')).toBeInTheDocument()
+ })
+
+ it('renders an error message when the query returns an error', () => {
+ mockUseGetConfigApiProperties.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ error: { message: 'Boom' },
+ refetch: jest.fn(),
+ })
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Boom/i)).toBeInTheDocument()
+ expect(screen.queryByTestId('config-api-properties-form')).not.toBeInTheDocument()
+ })
+})
diff --git a/admin-ui/plugins/auth-server/components/JsonViewer/__tests__/components/JsonViewer.test.tsx b/admin-ui/plugins/auth-server/components/JsonViewer/__tests__/components/JsonViewer.test.tsx
new file mode 100644
index 000000000..edf1c6e8d
--- /dev/null
+++ b/admin-ui/plugins/auth-server/components/JsonViewer/__tests__/components/JsonViewer.test.tsx
@@ -0,0 +1,47 @@
+import { render, screen } from '@testing-library/react'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import JsonViewer from 'Plugins/auth-server/components/JsonViewer/components/JsonViewer'
+import { THEME_DARK, THEME_LIGHT } from '@/context/theme/constants'
+
+const renderViewer = (ui: React.ReactElement) => render(ui, { wrapper: AppTestWrapper })
+
+describe('JsonViewer', () => {
+ it('renders object data', () => {
+ const { container } = renderViewer()
+ expect(container.querySelector('.json-viewer')).toBeInTheDocument()
+ expect(screen.getByText(/name/)).toBeInTheDocument()
+ })
+
+ it('renders array data', () => {
+ const { container } = renderViewer()
+ expect(container.querySelector('.json-viewer')).toBeInTheDocument()
+ })
+
+ it('falls back to an empty object for non-object data without crashing', () => {
+ const { container } = renderViewer()
+ expect(container.querySelector('.json-viewer')).toBeInTheDocument()
+ })
+
+ it('applies a custom className', () => {
+ const { container } = renderViewer()
+ expect(container.querySelector('.json-viewer.custom-cls')).toBeInTheDocument()
+ })
+
+ it('renders with the dark theme', () => {
+ const { container } = renderViewer()
+ expect(container.querySelector('.json-viewer')).toBeInTheDocument()
+ })
+
+ it('renders with the light theme', () => {
+ const { container } = renderViewer()
+ expect(container.querySelector('.json-viewer')).toBeInTheDocument()
+ })
+
+ it('applies a custom background color', () => {
+ const { container } = renderViewer(
+ ,
+ )
+ const el = container.querySelector('.json-viewer') as HTMLElement
+ expect(el.style.backgroundColor).toBe('rgb(10, 20, 30)')
+ })
+})
diff --git a/admin-ui/plugins/auth-server/components/JsonViewer/__tests__/components/JsonViewerDialog.test.tsx b/admin-ui/plugins/auth-server/components/JsonViewer/__tests__/components/JsonViewerDialog.test.tsx
new file mode 100644
index 000000000..0e94d35e8
--- /dev/null
+++ b/admin-ui/plugins/auth-server/components/JsonViewer/__tests__/components/JsonViewerDialog.test.tsx
@@ -0,0 +1,129 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import JsonViewerDialog from 'Plugins/auth-server/components/JsonViewer/components/JsonViewerDialog'
+
+const createStore = () =>
+ configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession: true }) => state,
+ toastReducer: (
+ state = { showToast: false, message: '', type: 'success', onCloseRedirectUrl: '' },
+ action: { type: string; payload?: { message?: string; type?: string } },
+ ) => {
+ if (action.type === 'toast/updateToast') {
+ return { ...state, showToast: true, ...action.payload }
+ }
+ return state
+ },
+ noReducer: (state = {}) => state,
+ }),
+ })
+
+type DialogProps = React.ComponentProps
+
+const renderDialog = (props: Partial = {}) => {
+ const toggle = props.toggle ?? jest.fn()
+ const store = createStore()
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+ const utils = render(
+ ,
+ { wrapper: Wrapper },
+ )
+ return { ...utils, toggle, store }
+}
+
+describe('JsonViewerDialog', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ Object.assign(navigator, {
+ clipboard: { writeText: jest.fn().mockResolvedValue(undefined) },
+ })
+ })
+
+ it('does not render when isOpen is false', () => {
+ const toggle = jest.fn()
+ const store = createStore()
+ const { container } = render(
+
+
+
+
+ ,
+ )
+ expect(container.querySelector('[role="dialog"]')).not.toBeInTheDocument()
+ })
+
+ it('renders the dialog with the default title when open', () => {
+ renderDialog()
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText('JSON View')).toBeInTheDocument()
+ })
+
+ it('renders a custom title', () => {
+ renderDialog({ title: 'SSA Details' })
+ expect(screen.getByText('SSA Details')).toBeInTheDocument()
+ })
+
+ it('renders the copy-to-clipboard button', () => {
+ renderDialog()
+ expect(screen.getByText(/Copy To Clipboard/i)).toBeInTheDocument()
+ })
+
+ it('copies data to the clipboard and switches the label to Copied', async () => {
+ renderDialog({ data: { hello: 'world' } })
+ const copyBtn = screen.getByText(/Copy To Clipboard/i)
+ await act(async () => {
+ fireEvent.click(copyBtn)
+ })
+ await waitFor(() => {
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ JSON.stringify({ hello: 'world' }, null, 2),
+ )
+ })
+ await waitFor(() => {
+ expect(screen.getByText(/Copied/i)).toBeInTheDocument()
+ })
+ })
+
+ it('copies a string payload as-is', async () => {
+ renderDialog({ data: 'plain-string' })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Copy To Clipboard/i))
+ })
+ await waitFor(() => {
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('plain-string')
+ })
+ })
+
+ it('dispatches an error toast when clipboard copy fails', async () => {
+ Object.assign(navigator, {
+ clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) },
+ })
+ const { store } = renderDialog({ data: { a: 1 } })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Copy To Clipboard/i))
+ })
+ await waitFor(() => {
+ expect(store.getState().toastReducer.showToast).toBe(true)
+ })
+ })
+
+ it('calls toggle when the close button is clicked', () => {
+ const { toggle } = renderDialog()
+ fireEvent.click(screen.getByTitle('Close'))
+ expect(toggle).toHaveBeenCalled()
+ })
+
+ it('disables the copy button when data is undefined', () => {
+ renderDialog({ data: undefined })
+ const copyBtn = screen.getByText(/Copy To Clipboard/i).closest('button')
+ expect(copyBtn).toBeDisabled()
+ })
+})
diff --git a/admin-ui/plugins/auth-server/components/Ssa/__tests__/components/SsaForm.test.tsx b/admin-ui/plugins/auth-server/components/Ssa/__tests__/components/SsaForm.test.tsx
new file mode 100644
index 000000000..387be7869
--- /dev/null
+++ b/admin-ui/plugins/auth-server/components/Ssa/__tests__/components/SsaForm.test.tsx
@@ -0,0 +1,103 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { createSsaTestStore, createSsaTestWrapper } from '../helpers/ssaTestUtils'
+import SsaForm from '../../components/SsaForm'
+
+const defaultProps = {
+ onSubmitData: jest.fn().mockResolvedValue(undefined),
+ isSubmitting: false,
+ customAttributes: ['customAttr1', 'customAttr2'],
+ softwareRolesOptions: ['role1', 'role2'],
+}
+
+describe('SsaForm', () => {
+ let Wrapper: React.ComponentType<{ children: React.ReactNode }>
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ const store = createSsaTestStore()
+ Wrapper = createSsaTestWrapper(store)
+ })
+
+ describe('rendering', () => {
+ it('renders without crashing and shows the required fields', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Software ID/i)).toBeInTheDocument()
+ expect(screen.getByText(/Organization/i)).toBeInTheDocument()
+ expect(screen.getByText(/Description/i)).toBeInTheDocument()
+ })
+
+ it('renders the software roles and grant types fields', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Software Roles/i)).toBeInTheDocument()
+ expect(screen.getByText(/Grants/i)).toBeInTheDocument()
+ })
+
+ it('renders footer Back, Cancel and Apply buttons', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Back/i)).toBeInTheDocument()
+ expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/Apply/i)).toBeInTheDocument()
+ })
+
+ it('renders the toggle rows for one time use, rotate ssa and is expirable', () => {
+ render(, { wrapper: Wrapper })
+ expect(document.querySelector('input#one_time_use')).toBeTruthy()
+ expect(document.querySelector('input#rotate_ssa')).toBeTruthy()
+ expect(document.querySelector('input#is_expirable')).toBeTruthy()
+ })
+ })
+
+ describe('available custom attributes panel', () => {
+ it('renders the available custom attributes', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('customAttr1')).toBeInTheDocument()
+ expect(screen.getByText('customAttr2')).toBeInTheDocument()
+ })
+ })
+
+ describe('footer state', () => {
+ it('disables the Cancel button when the form is not dirty', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Cancel/i).closest('button')).toBeDisabled()
+ })
+
+ it('disables the Apply button when the form is not dirty', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText(/Apply/i).closest('button')).toBeDisabled()
+ })
+ })
+
+ describe('expiration date', () => {
+ it('does not render the date picker until is_expirable is enabled', () => {
+ render(, { wrapper: Wrapper })
+ const expirableToggle = document.querySelector(
+ 'input#is_expirable[type="checkbox"]',
+ ) as HTMLInputElement
+ expect(expirableToggle.checked).toBe(false)
+ })
+
+ it('shows the date picker after toggling is_expirable on', async () => {
+ render(, { wrapper: Wrapper })
+ const expirableToggle = document.querySelector(
+ 'input#is_expirable[type="checkbox"]',
+ ) as HTMLInputElement
+ fireEvent.click(expirableToggle)
+ await waitFor(() => {
+ expect(expirableToggle.checked).toBe(true)
+ })
+ })
+ })
+
+ describe('interactions', () => {
+ it('allows typing into the software id field', async () => {
+ render(, { wrapper: Wrapper })
+ const input = document.querySelector('input#software_id') as HTMLInputElement
+ expect(input).toBeTruthy()
+ fireEvent.change(input, { target: { value: 'my-software' } })
+ await waitFor(() => {
+ expect(input.value).toBe('my-software')
+ })
+ })
+ })
+})
diff --git a/admin-ui/plugins/fido/__tests__/components/Metrics/useMetricsApi.test.tsx b/admin-ui/plugins/fido/__tests__/components/Metrics/useMetricsApi.test.tsx
new file mode 100644
index 000000000..83645064b
--- /dev/null
+++ b/admin-ui/plugins/fido/__tests__/components/Metrics/useMetricsApi.test.tsx
@@ -0,0 +1,166 @@
+import React from 'react'
+import { renderHook, waitFor } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import {
+ useAdoptionMetrics,
+ useErrorsAnalytics,
+ usePerformanceAnalytics,
+ useAggregationMetrics,
+} from 'Plugins/fido/components/Metrics/hooks/useMetricsApi'
+import { createDate } from '@/utils/dayjsUtils'
+import type { MetricsDateRange } from 'Plugins/fido/components/Metrics/types'
+
+const mockGet = jest.fn()
+
+jest.mock('Orval', () => ({
+ ...jest.requireActual('Orval'),
+ AXIOS_INSTANCE: {
+ get: (url: string, config?: { params?: Record }) =>
+ mockGet(url, config),
+ },
+}))
+
+const buildStore = (hasSession = true) =>
+ configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession }) => state,
+ toastReducer: (state = { showToast: false, message: '', type: 'success' }) => state,
+ noReducer: (state = {}) => state,
+ }),
+ })
+
+const createWrapper = (store: ReturnType) => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+const dateRange: MetricsDateRange = {
+ startDate: createDate('2024-01-01'),
+ endDate: createDate('2024-01-31'),
+}
+
+describe('useMetricsApi', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockGet.mockResolvedValue({ data: { adoptionRate: 42 } })
+ })
+
+ describe('useAdoptionMetrics', () => {
+ it('fetches adoption metrics when enabled with a ready date range', async () => {
+ const store = buildStore(true)
+ const { result } = renderHook(() => useAdoptionMetrics(dateRange), {
+ wrapper: createWrapper(store),
+ })
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true)
+ })
+ expect(mockGet).toHaveBeenCalledWith(
+ '/fido2/metrics/analytics/adoption',
+ expect.objectContaining({ params: expect.any(Object) }),
+ )
+ expect(result.current.data).toEqual({ adoptionRate: 42 })
+ })
+
+ it('does not fetch when there is no session', () => {
+ const store = buildStore(false)
+ renderHook(() => useAdoptionMetrics(dateRange), { wrapper: createWrapper(store) })
+ expect(mockGet).not.toHaveBeenCalled()
+ })
+
+ it('does not fetch when the date range is null', () => {
+ const store = buildStore(true)
+ renderHook(() => useAdoptionMetrics(null), { wrapper: createWrapper(store) })
+ expect(mockGet).not.toHaveBeenCalled()
+ })
+
+ it('does not fetch when explicitly disabled', () => {
+ const store = buildStore(true)
+ renderHook(() => useAdoptionMetrics(dateRange, { enabled: false }), {
+ wrapper: createWrapper(store),
+ })
+ expect(mockGet).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('useErrorsAnalytics', () => {
+ it('fetches the errors analytics endpoint', async () => {
+ const store = buildStore(true)
+ const { result } = renderHook(() => useErrorsAnalytics(dateRange), {
+ wrapper: createWrapper(store),
+ })
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true)
+ })
+ expect(mockGet).toHaveBeenCalledWith(
+ '/fido2/metrics/analytics/errors',
+ expect.objectContaining({ params: expect.any(Object) }),
+ )
+ })
+ })
+
+ describe('usePerformanceAnalytics', () => {
+ it('fetches the performance analytics endpoint', async () => {
+ const store = buildStore(true)
+ const { result } = renderHook(() => usePerformanceAnalytics(dateRange), {
+ wrapper: createWrapper(store),
+ })
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true)
+ })
+ expect(mockGet).toHaveBeenCalledWith(
+ '/fido2/metrics/analytics/performance',
+ expect.objectContaining({ params: expect.any(Object) }),
+ )
+ })
+ })
+
+ describe('useAggregationMetrics', () => {
+ it('fetches the aggregation endpoint for the given type', async () => {
+ const store = buildStore(true)
+ const { result } = renderHook(() => useAggregationMetrics('Daily', dateRange), {
+ wrapper: createWrapper(store),
+ })
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true)
+ })
+ expect(mockGet).toHaveBeenCalledWith(
+ '/fido2/metrics/aggregations/Daily',
+ expect.objectContaining({
+ params: expect.objectContaining({ limit: 50, startIndex: 0 }),
+ }),
+ )
+ })
+
+ it('does not fetch when the date range is null', () => {
+ const store = buildStore(true)
+ renderHook(() => useAggregationMetrics('Daily', null), { wrapper: createWrapper(store) })
+ expect(mockGet).not.toHaveBeenCalled()
+ })
+
+ it('dispatches an error toast when the request fails', async () => {
+ mockGet.mockRejectedValueOnce(new Error('network'))
+ const store = buildStore(true)
+ const dispatchSpy = jest.spyOn(store, 'dispatch')
+ const { result } = renderHook(() => useAggregationMetrics('Weekly', dateRange), {
+ wrapper: createWrapper(store),
+ })
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true)
+ })
+ await waitFor(() => {
+ expect(dispatchSpy).toHaveBeenCalled()
+ })
+ })
+ })
+})
diff --git a/admin-ui/plugins/fido/__tests__/components/Metrics/utils.test.ts b/admin-ui/plugins/fido/__tests__/components/Metrics/utils.test.ts
new file mode 100644
index 000000000..07f6d9f66
--- /dev/null
+++ b/admin-ui/plugins/fido/__tests__/components/Metrics/utils.test.ts
@@ -0,0 +1,272 @@
+import {
+ interpolateHeatmapColor,
+ getNiceStep,
+ toNumber,
+ toPercent,
+ formatChartValue,
+ formatNonZeroChartValue,
+ buildRangeLabel,
+ entriesToActivityData,
+ entriesToHeatmapData,
+ entriesToHourlyHeatmap,
+} from 'Plugins/fido/components/Metrics/utils'
+import { createDate } from '@/utils/dayjsUtils'
+import type { TFunction } from 'i18next'
+import type { AggregationEntry, MetricsDateRange } from 'Plugins/fido/components/Metrics/types'
+import type { AggregationType } from 'Plugins/fido/components/Metrics/constants'
+
+const t = ((key: string, opts?: Record) =>
+ opts ? `${key}:${JSON.stringify(opts)}` : key) as TFunction
+
+describe('Metrics utils', () => {
+ describe('interpolateHeatmapColor', () => {
+ it('returns an rgb color string', () => {
+ expect(interpolateHeatmapColor(5, 0, 10)).toMatch(/^rgb\(\d+,\d+,\d+\)$/)
+ })
+
+ it('clamps values below the minimum to the first stop', () => {
+ const atMin = interpolateHeatmapColor(0, 0, 10)
+ const belowMin = interpolateHeatmapColor(-5, 0, 10)
+ expect(belowMin).toBe(atMin)
+ })
+
+ it('clamps values above the maximum to the last stop', () => {
+ const atMax = interpolateHeatmapColor(10, 0, 10)
+ const aboveMax = interpolateHeatmapColor(50, 0, 10)
+ expect(aboveMax).toBe(atMax)
+ })
+
+ it('produces different colors across the range', () => {
+ const low = interpolateHeatmapColor(1, 0, 10)
+ const high = interpolateHeatmapColor(9, 0, 10)
+ expect(low).not.toBe(high)
+ })
+ })
+
+ describe('getNiceStep', () => {
+ it('returns 1 when the range is zero or negative', () => {
+ expect(getNiceStep(5, 5)).toBe(1)
+ expect(getNiceStep(10, 5)).toBe(1)
+ })
+
+ it('returns a positive step for a normal range', () => {
+ expect(getNiceStep(0, 100)).toBeGreaterThan(0)
+ })
+
+ it('never returns less than 1', () => {
+ expect(getNiceStep(0, 0.5)).toBeGreaterThanOrEqual(1)
+ })
+
+ it('scales with the magnitude of the range', () => {
+ const small = getNiceStep(0, 10)
+ const large = getNiceStep(0, 10000)
+ expect(large).toBeGreaterThan(small)
+ })
+ })
+
+ describe('toNumber', () => {
+ it('returns finite numbers unchanged', () => {
+ expect(toNumber(42)).toBe(42)
+ expect(toNumber(0)).toBe(0)
+ })
+
+ it('returns 0 for non-numbers', () => {
+ expect(toNumber('5')).toBe(0)
+ expect(toNumber(null)).toBe(0)
+ expect(toNumber(undefined)).toBe(0)
+ expect(toNumber(true)).toBe(0)
+ })
+
+ it('returns 0 for non-finite numbers', () => {
+ expect(toNumber(Infinity)).toBe(0)
+ expect(toNumber(NaN)).toBe(0)
+ })
+ })
+
+ describe('toPercent', () => {
+ it('returns 0 for non-numbers and NaN', () => {
+ expect(toPercent(null)).toBe(0)
+ expect(toPercent(undefined)).toBe(0)
+ expect(toPercent(NaN)).toBe(0)
+ })
+
+ it('treats fractions (<= 1) as ratios and scales to percent', () => {
+ expect(toPercent(0.5)).toBe(50)
+ expect(toPercent(1)).toBe(100)
+ })
+
+ it('treats values > 1 as already a percent', () => {
+ expect(toPercent(75)).toBe(75)
+ })
+
+ it('clamps to the 0-100 range', () => {
+ expect(toPercent(150)).toBe(100)
+ expect(toPercent(-10)).toBe(0)
+ })
+ })
+
+ describe('formatChartValue', () => {
+ it('formats numeric values to at most 2 decimals', () => {
+ expect(formatChartValue(3.14159)).toBe('3.14')
+ expect(formatChartValue(5)).toBe('5')
+ })
+
+ it('parses numeric strings', () => {
+ expect(formatChartValue('10.5')).toBe('10.5')
+ })
+
+ it('returns empty string for unparsable values', () => {
+ expect(formatChartValue('abc')).toBe('')
+ expect(formatChartValue(undefined)).toBe('')
+ })
+
+ it('keeps zero as "0"', () => {
+ expect(formatChartValue(0)).toBe('0')
+ })
+ })
+
+ describe('formatNonZeroChartValue', () => {
+ it('formats positive values', () => {
+ expect(formatNonZeroChartValue(2.5)).toBe('2.5')
+ })
+
+ it('returns empty string for zero and negative values', () => {
+ expect(formatNonZeroChartValue(0)).toBe('')
+ expect(formatNonZeroChartValue(-3)).toBe('')
+ })
+
+ it('returns empty string for non-finite values', () => {
+ expect(formatNonZeroChartValue('xyz')).toBe('')
+ expect(formatNonZeroChartValue(null)).toBe('')
+ })
+ })
+
+ describe('buildRangeLabel', () => {
+ const range: MetricsDateRange = {
+ startDate: createDate('2024-01-01'),
+ endDate: createDate('2024-01-31'),
+ }
+
+ it('builds a label including the range type', () => {
+ const label = buildRangeLabel('daily' as AggregationType, range, t)
+ expect(label).toContain('fields.agg_range_label')
+ expect(label).toContain('fields.agg_type_daily')
+ })
+
+ it('handles weekly and monthly aggregation types', () => {
+ expect(buildRangeLabel('weekly' as AggregationType, range, t)).toContain(
+ 'fields.agg_type_weekly',
+ )
+ expect(buildRangeLabel('monthly' as AggregationType, range, t)).toContain(
+ 'fields.agg_type_monthly',
+ )
+ })
+ })
+
+ describe('entriesToActivityData', () => {
+ const entries: AggregationEntry[] = [
+ {
+ period: '2024-01-01',
+ registrationSuccesses: 5,
+ registrationAttempts: 8,
+ authenticationAttempts: 10,
+ authenticationSuccesses: 9,
+ },
+ ]
+
+ it('maps entries into activity data points', () => {
+ const result = entriesToActivityData(entries, 'daily' as AggregationType)
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({
+ regSuccess: 5,
+ regAttempts: 8,
+ authAttempts: 10,
+ authSuccess: 9,
+ })
+ expect(typeof result[0]!.label).toBe('string')
+ })
+
+ it('defaults missing numeric fields to 0', () => {
+ const result = entriesToActivityData([{ period: '2024-01-02' }])
+ expect(result[0]).toMatchObject({
+ regSuccess: 0,
+ regAttempts: 0,
+ authAttempts: 0,
+ authSuccess: 0,
+ })
+ })
+
+ it('formats hourly labels for the hourly aggregation type', () => {
+ const result = entriesToActivityData(
+ [{ period: '2024-01-01-14', registrationSuccesses: 1 }],
+ 'hourly' as AggregationType,
+ )
+ expect(result[0]!.label).toContain('H')
+ })
+
+ it('returns an empty array for no entries', () => {
+ expect(entriesToActivityData([])).toEqual([])
+ })
+ })
+
+ describe('entriesToHeatmapData', () => {
+ const entries: AggregationEntry[] = [
+ { period: '2024-01-01', authenticationAvgDuration: 100, registrationAvgDuration: 50 },
+ { period: '2024-01-02', authenticationAvgDuration: 200, registrationAvgDuration: 80 },
+ ]
+
+ it('returns two rows for authentication and registration', () => {
+ const data = entriesToHeatmapData(entries, 'daily' as AggregationType, t)
+ expect(data.rows).toHaveLength(2)
+ expect(data.data).toHaveLength(2)
+ expect(data.cols).toHaveLength(2)
+ })
+
+ it('computes min and max from durations', () => {
+ const data = entriesToHeatmapData(entries, 'daily' as AggregationType, t)
+ expect(data.minVal).toBeGreaterThanOrEqual(0)
+ expect(data.maxVal).toBeGreaterThan(data.minVal)
+ })
+
+ it('adds colsSub for the weekly aggregation type', () => {
+ const data = entriesToHeatmapData(entries, 'weekly' as AggregationType, t)
+ expect(data.colsSub).toBeDefined()
+ expect(data.colsSub).toHaveLength(2)
+ })
+
+ it('handles empty entries without crashing', () => {
+ const data = entriesToHeatmapData([], 'daily' as AggregationType, t)
+ expect(data.cols).toEqual([])
+ expect(data.maxVal).toBeGreaterThan(data.minVal)
+ })
+ })
+
+ describe('entriesToHourlyHeatmap', () => {
+ const entries: AggregationEntry[] = [
+ { period: '2024-01-01-09', registrationAvgDuration: 30, authenticationAvgDuration: 40 },
+ { period: '2024-01-01-10', registrationAvgDuration: 60, authenticationAvgDuration: 70 },
+ ]
+
+ it('builds a 24-column heatmap for registration', () => {
+ const data = entriesToHourlyHeatmap(entries, 'registration')
+ expect(data.cols).toHaveLength(24)
+ expect(data.rows.length).toBeGreaterThan(0)
+ })
+
+ it('builds a heatmap for authentication', () => {
+ const data = entriesToHourlyHeatmap(entries, 'authentication')
+ expect(data.cols).toHaveLength(24)
+ })
+
+ it('skips entries that have no parsable hourly period', () => {
+ const data = entriesToHourlyHeatmap([{ id: 'no-period' }], 'registration')
+ expect(data.rows).toHaveLength(0)
+ })
+
+ it('computes min and max bounds', () => {
+ const data = entriesToHourlyHeatmap(entries, 'registration')
+ expect(data.minVal).toBeGreaterThanOrEqual(0)
+ expect(data.maxVal).toBeGreaterThan(data.minVal)
+ })
+ })
+})
diff --git a/admin-ui/plugins/fido/__tests__/helper/validations.test.ts b/admin-ui/plugins/fido/__tests__/helper/validations.test.ts
new file mode 100644
index 000000000..e1861f1c0
--- /dev/null
+++ b/admin-ui/plugins/fido/__tests__/helper/validations.test.ts
@@ -0,0 +1,139 @@
+import {
+ isLastKeyValueComplete,
+ isLastStringEntryComplete,
+ isLastMetadataServerComplete,
+ validationSchema,
+} from 'Plugins/fido/helper/validations'
+
+describe('fido validations', () => {
+ describe('isLastKeyValueComplete', () => {
+ it('returns true for an empty list', () => {
+ expect(isLastKeyValueComplete([])).toBe(true)
+ })
+
+ it('returns true when the last row has both key and value', () => {
+ expect(isLastKeyValueComplete([{ key: 'a', value: '1' }])).toBe(true)
+ })
+
+ it('returns false when the last row is missing the value', () => {
+ expect(isLastKeyValueComplete([{ key: 'a', value: '' }])).toBe(false)
+ })
+
+ it('returns false when the last row is missing the key', () => {
+ expect(isLastKeyValueComplete([{ key: ' ', value: '1' }])).toBe(false)
+ })
+
+ it('only inspects the last row', () => {
+ expect(
+ isLastKeyValueComplete([
+ { key: '', value: '' },
+ { key: 'b', value: '2' },
+ ]),
+ ).toBe(true)
+ })
+
+ it('handles undefined key/value', () => {
+ expect(isLastKeyValueComplete([{}])).toBe(false)
+ })
+ })
+
+ describe('isLastStringEntryComplete', () => {
+ it('returns true for an empty list', () => {
+ expect(isLastStringEntryComplete([])).toBe(true)
+ })
+
+ it('returns true when the last entry is non-empty', () => {
+ expect(isLastStringEntryComplete(['value'])).toBe(true)
+ })
+
+ it('returns false when the last entry is blank', () => {
+ expect(isLastStringEntryComplete([' '])).toBe(false)
+ expect(isLastStringEntryComplete([''])).toBe(false)
+ })
+
+ it('only inspects the last entry', () => {
+ expect(isLastStringEntryComplete(['', 'ok'])).toBe(true)
+ })
+ })
+
+ describe('isLastMetadataServerComplete', () => {
+ it('returns true for an empty list', () => {
+ expect(isLastMetadataServerComplete([])).toBe(true)
+ })
+
+ it('returns true when the last server has url and rootCert', () => {
+ expect(isLastMetadataServerComplete([{ url: 'https://mds', rootCert: 'cert' }])).toBe(true)
+ })
+
+ it('returns false when the last server is missing rootCert', () => {
+ expect(isLastMetadataServerComplete([{ url: 'https://mds', rootCert: '' }])).toBe(false)
+ })
+
+ it('returns false when the last server is missing url', () => {
+ expect(isLastMetadataServerComplete([{ url: '', rootCert: 'cert' }])).toBe(false)
+ })
+
+ it('handles undefined fields', () => {
+ expect(isLastMetadataServerComplete([{}])).toBe(false)
+ })
+ })
+
+ describe('validationSchema', () => {
+ it('exposes the dynamic and static config schemas', () => {
+ expect(validationSchema.dynamicConfigValidationSchema).toBeDefined()
+ expect(validationSchema.staticConfigValidationSchema).toBeDefined()
+ })
+
+ it('rejects a dynamic config missing required fields', async () => {
+ await expect(validationSchema.dynamicConfigValidationSchema.validate({})).rejects.toBeTruthy()
+ })
+
+ it('accepts a valid dynamic config', async () => {
+ const valid = {
+ issuer: 'https://issuer',
+ baseEndpoint: 'https://issuer/fido2',
+ cleanServiceInterval: 60,
+ cleanServiceBatchChunkSize: 100,
+ useLocalCache: true,
+ disableJdkLogger: false,
+ loggingLevel: 'INFO',
+ loggingLayout: 'text',
+ metricReporterEnabled: true,
+ metricReporterInterval: 300,
+ metricReporterKeepDataDays: 15,
+ fido2MetricsEnabled: true,
+ fido2MetricsRetentionDays: 30,
+ fido2DeviceInfoCollection: true,
+ fido2ErrorCategorization: true,
+ fido2PerformanceMetrics: true,
+ }
+ await expect(
+ validationSchema.dynamicConfigValidationSchema.validate(valid),
+ ).resolves.toBeTruthy()
+ })
+
+ it('rejects a dynamic config with a non-numeric interval', async () => {
+ await expect(
+ validationSchema.dynamicConfigValidationSchema.validateAt('cleanServiceInterval', {
+ cleanServiceInterval: 'not-a-number',
+ }),
+ ).rejects.toBeTruthy()
+ })
+
+ it('rejects a static config with an invalid attestation mode', async () => {
+ await expect(
+ validationSchema.staticConfigValidationSchema.validateAt('attestationMode', {
+ attestationMode: 'INVALID_MODE',
+ }),
+ ).rejects.toBeTruthy()
+ })
+
+ it('rejects a static config requestedParties row with empty key/value', async () => {
+ await expect(
+ validationSchema.staticConfigValidationSchema.validateAt('requestedParties', {
+ requestedParties: [{ key: '', value: '' }],
+ }),
+ ).rejects.toBeTruthy()
+ })
+ })
+})
diff --git a/admin-ui/plugins/fido/__tests__/hooks/useFidoApi.test.tsx b/admin-ui/plugins/fido/__tests__/hooks/useFidoApi.test.tsx
new file mode 100644
index 000000000..b6ce7ee34
--- /dev/null
+++ b/admin-ui/plugins/fido/__tests__/hooks/useFidoApi.test.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import { renderHook, act, waitFor } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import { useFidoConfig, useUpdateFidoConfig } from 'Plugins/fido/hooks/useFidoApi'
+import type { UpdateFidoParams } from 'Plugins/fido/types'
+import type { LogAuditParams } from 'Utils/AuditLogger'
+import type { JsonValue } from 'Routes/Apps/Gluu/types/common'
+
+const dynamicParams: UpdateFidoParams = {
+ type: 'dynamic',
+ userMessage: 'update',
+ data: {
+ issuer: 'https://updated',
+ baseEndpoint: 'https://updated/fido2',
+ cleanServiceInterval: 60,
+ cleanServiceBatchChunkSize: 100,
+ useLocalCache: true,
+ disableJdkLogger: false,
+ loggingLevel: 'INFO',
+ loggingLayout: 'text',
+ metricReporterEnabled: true,
+ metricReporterInterval: 300,
+ metricReporterKeepDataDays: 15,
+ personCustomObjectClassList: [],
+ fido2MetricsEnabled: true,
+ fido2MetricsRetentionDays: 30,
+ fido2DeviceInfoCollection: true,
+ fido2ErrorCategorization: true,
+ fido2PerformanceMetrics: true,
+ },
+}
+
+const mockGetQuery = jest.fn()
+const mockPutMutate = jest.fn()
+const mockLogAuditUserAction = jest.fn()
+const mockTriggerWebhook = jest.fn()
+
+jest.mock('JansConfigApi', () => ({
+ useGetPropertiesFido2: () => mockGetQuery(),
+ usePutPropertiesFido2: () => ({ mutate: mockPutMutate, isPending: false }),
+ getGetPropertiesFido2QueryKey: () => ['fido2', 'properties'],
+}))
+
+jest.mock('Utils/AuditLogger', () => ({
+ logAuditUserAction: (params: LogAuditParams) => mockLogAuditUserAction(params),
+}))
+
+jest.mock('@/utils/triggerWebhookForFeature', () => ({
+ triggerWebhookForFeature: (data: Record, feature: string) =>
+ mockTriggerWebhook(data, feature),
+}))
+
+const buildStore = (hasSession = true) =>
+ configureStore({
+ reducer: combineReducers({
+ authReducer: (
+ state = {
+ hasSession,
+ userinfo: { inum: 'inum-1', name: 'admin' },
+ config: { clientId: 'client-1' },
+ location: { IPv4: '127.0.0.1' },
+ },
+ ) => state,
+ toastReducer: (state = { showToast: false, message: '', type: 'success' }) => state,
+ noReducer: (state = {}) => state,
+ }),
+ })
+
+const createWrapper = (store: ReturnType) => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+describe('useFidoApi', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockLogAuditUserAction.mockResolvedValue(undefined)
+ mockGetQuery.mockReturnValue({
+ data: { issuer: 'https://issuer', fido2Configuration: { mdsCertsFolder: '/certs' } },
+ isError: false,
+ error: null,
+ })
+ })
+
+ describe('useFidoConfig', () => {
+ it('returns the underlying query object', () => {
+ const store = buildStore(true)
+ const { result } = renderHook(() => useFidoConfig(), { wrapper: createWrapper(store) })
+ expect(result.current.data).toBeDefined()
+ expect(result.current.isError).toBe(false)
+ })
+
+ it('does not dispatch a toast when there is no error', () => {
+ const store = buildStore(true)
+ const dispatchSpy = jest.spyOn(store, 'dispatch')
+ renderHook(() => useFidoConfig(), { wrapper: createWrapper(store) })
+ expect(dispatchSpy).not.toHaveBeenCalled()
+ })
+
+ it('dispatches an error toast when the query errors', async () => {
+ mockGetQuery.mockReturnValue({
+ data: undefined,
+ isError: true,
+ error: new Error('boom'),
+ })
+ const store = buildStore(true)
+ const dispatchSpy = jest.spyOn(store, 'dispatch')
+ renderHook(() => useFidoConfig(), { wrapper: createWrapper(store) })
+ await waitFor(() => {
+ expect(dispatchSpy).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('useUpdateFidoConfig', () => {
+ it('exposes a mutate function', () => {
+ const store = buildStore(true)
+ const { result } = renderHook(() => useUpdateFidoConfig(), { wrapper: createWrapper(store) })
+ expect(typeof result.current.mutate).toBe('function')
+ })
+
+ it('dispatches an error toast when no configuration is loaded', () => {
+ mockGetQuery.mockReturnValue({ data: undefined, isError: false, error: null })
+ const store = buildStore(true)
+ const dispatchSpy = jest.spyOn(store, 'dispatch')
+ const { result } = renderHook(() => useUpdateFidoConfig(), { wrapper: createWrapper(store) })
+
+ act(() => {
+ result.current.mutate(dynamicParams)
+ })
+
+ expect(dispatchSpy).toHaveBeenCalled()
+ expect(mockPutMutate).not.toHaveBeenCalled()
+ })
+
+ it('calls the underlying mutation when configuration is loaded', () => {
+ const store = buildStore(true)
+ const { result } = renderHook(() => useUpdateFidoConfig(), { wrapper: createWrapper(store) })
+
+ act(() => {
+ result.current.mutate(dynamicParams)
+ })
+
+ expect(mockPutMutate).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/admin-ui/plugins/saml/__tests__/components/SamlConfigurationForm.test.tsx b/admin-ui/plugins/saml/__tests__/components/SamlConfigurationForm.test.tsx
new file mode 100644
index 000000000..1889f6177
--- /dev/null
+++ b/admin-ui/plugins/saml/__tests__/components/SamlConfigurationForm.test.tsx
@@ -0,0 +1,169 @@
+import React, { act } from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import type { SamlAppConfiguration } from 'Plugins/saml/components/hooks'
+
+const mockState: { configuration: SamlAppConfiguration | undefined; canWrite: boolean } = {
+ configuration: undefined,
+ canWrite: true,
+}
+
+const mockUpdateMutate = jest.fn()
+
+jest.mock('Plugins/saml/components/hooks', () => ({
+ useSamlConfiguration: () => ({ data: mockState.configuration, isLoading: false }),
+ useUpdateSamlConfiguration: () => ({
+ mutateAsync: mockUpdateMutate,
+ isPending: false,
+ }),
+}))
+
+jest.mock('@/cedarling/hooks/usePermission', () => ({
+ usePermission: () => ({ canRead: true, canWrite: mockState.canWrite, canDelete: true }),
+}))
+
+jest.mock('@/cedarling/utility', () => ({
+ ADMIN_UI_RESOURCES: { SAML: 'SAML' },
+ CEDAR_RESOURCE_SCOPES: { SAML: ['read', 'write', 'delete'] },
+}))
+
+jest.mock('Routes/Apps/Gluu/GluuCommitDialog', () => ({
+ __esModule: true,
+ default: () => null,
+}))
+
+import SamlConfigurationForm from 'Plugins/saml/components/SamlConfigurationForm'
+
+const store = configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession: true, permissions: [], config: {} }) => state,
+ cedarPermissions: (state = { permissions: {}, initialized: true, isInitializing: false }) =>
+ state,
+ webhookReducer: (
+ state = {
+ featureWebhooks: [],
+ loadingWebhooks: false,
+ webhookModal: false,
+ triggerWebhookInProgress: false,
+ },
+ ) => state,
+ noReducer: (state = {}) => state,
+ }),
+})
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+const renderForm = async () => {
+ let result: ReturnType
+ await act(async () => {
+ result = render(, { wrapper: Wrapper })
+ })
+ await screen.findByTestId('selectedIdp')
+ return result!
+}
+
+describe('SamlConfigurationForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockState.configuration = undefined
+ mockState.canWrite = true
+ })
+
+ describe('with empty configuration', () => {
+ it('renders without crashing', async () => {
+ await renderForm()
+ expect(screen.getByTestId('selectedIdp')).toBeInTheDocument()
+ })
+
+ it('defaults the selected idp select to empty', async () => {
+ await renderForm()
+ const select = screen.getByTestId('selectedIdp') as HTMLSelectElement
+ expect(select.value).toBe('')
+ })
+
+ it('renders the enable saml toggle as unchecked', async () => {
+ await renderForm()
+ const toggle = document.querySelector('input#enabled[type="checkbox"]') as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(false)
+ })
+
+ it('renders the ignore validation toggle as unchecked', async () => {
+ await renderForm()
+ const toggle = document.querySelector(
+ 'input#ignoreValidation[type="checkbox"]',
+ ) as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(false)
+ })
+
+ it('renders the Keycloak option in the idp select', async () => {
+ await renderForm()
+ expect(screen.getByText('Keycloak')).toBeInTheDocument()
+ })
+ })
+
+ describe('with existing configuration (edit mode)', () => {
+ beforeEach(() => {
+ mockState.configuration = {
+ enabled: true,
+ selectedIdp: 'keycloak',
+ ignoreValidation: true,
+ applicationName: 'My SAML App',
+ }
+ })
+
+ it('populates the selected idp from the configuration', async () => {
+ await renderForm()
+ const select = screen.getByTestId('selectedIdp') as HTMLSelectElement
+ expect(select.value).toBe('keycloak')
+ })
+
+ it('renders the enable saml toggle as checked', async () => {
+ await renderForm()
+ const toggle = document.querySelector('input#enabled[type="checkbox"]') as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(true)
+ })
+
+ it('renders the ignore validation toggle as checked', async () => {
+ await renderForm()
+ const toggle = document.querySelector(
+ 'input#ignoreValidation[type="checkbox"]',
+ ) as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(true)
+ })
+ })
+
+ describe('write permission gating', () => {
+ it('renders the footer when the user can write', async () => {
+ await renderForm()
+ expect(screen.getByText(/Apply/i)).toBeInTheDocument()
+ expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
+ })
+
+ it('hides the footer when the user cannot write', async () => {
+ mockState.canWrite = false
+ await renderForm()
+ expect(screen.queryByText(/Apply/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('form interactions', () => {
+ it('updates the selected idp when changed', async () => {
+ await renderForm()
+ const select = screen.getByTestId('selectedIdp') as HTMLSelectElement
+ await act(async () => {
+ fireEvent.change(select, { target: { value: 'keycloak' } })
+ })
+ expect(select.value).toBe('keycloak')
+ })
+ })
+})
diff --git a/admin-ui/plugins/saml/__tests__/components/SamlPage.test.tsx b/admin-ui/plugins/saml/__tests__/components/SamlPage.test.tsx
new file mode 100644
index 000000000..85c66e761
--- /dev/null
+++ b/admin-ui/plugins/saml/__tests__/components/SamlPage.test.tsx
@@ -0,0 +1,84 @@
+import React, { act } from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+
+jest.mock('Plugins/saml/components/SamlConfigurationForm', () => ({
+ __esModule: true,
+ default: () => Configuration Panel
,
+}))
+
+jest.mock('Plugins/saml/components/WebsiteSsoIdentityBrokeringList', () => ({
+ __esModule: true,
+ default: () => Identity Brokering Panel
,
+}))
+
+jest.mock('Plugins/saml/components/WebsiteSsoServiceProviderList', () => ({
+ __esModule: true,
+ default: () => Service Provider Panel
,
+}))
+
+import SamlPage from 'Plugins/saml/components/SamlPage'
+
+const store = configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession: true, permissions: [], config: {} }) => state,
+ cedarPermissions: (state = { permissions: {}, initialized: true, isInitializing: false }) =>
+ state,
+ noReducer: (state = {}) => state,
+ }),
+})
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+const renderPage = async () => {
+ let result: ReturnType
+ await act(async () => {
+ result = render(, { wrapper: Wrapper })
+ })
+ return result!
+}
+
+describe('SamlPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders without crashing', async () => {
+ await renderPage()
+ expect(screen.getByText('Configuration')).toBeInTheDocument()
+ })
+
+ it('renders all three tab labels', async () => {
+ await renderPage()
+ expect(screen.getByText('Configuration')).toBeInTheDocument()
+ expect(screen.getByText('Identity Brokering')).toBeInTheDocument()
+ expect(screen.getByText('Website SSO')).toBeInTheDocument()
+ })
+
+ it('shows the configuration panel by default', async () => {
+ await renderPage()
+ expect(screen.getByTestId('saml-configuration-form')).toBeInTheDocument()
+ })
+
+ it('switches to the identity providers panel when its tab is clicked', async () => {
+ await renderPage()
+ await act(async () => {
+ fireEvent.click(screen.getByText('Identity Brokering'))
+ })
+ expect(screen.getByTestId('saml-idp-list')).toBeInTheDocument()
+ })
+
+ it('switches to the service providers panel when its tab is clicked', async () => {
+ await renderPage()
+ await act(async () => {
+ fireEvent.click(screen.getByText('Website SSO'))
+ })
+ expect(screen.getByTestId('saml-sp-list')).toBeInTheDocument()
+ })
+})
diff --git a/admin-ui/plugins/saml/__tests__/components/WebsiteSsoIdentityProviderForm.test.tsx b/admin-ui/plugins/saml/__tests__/components/WebsiteSsoIdentityProviderForm.test.tsx
new file mode 100644
index 000000000..41e555529
--- /dev/null
+++ b/admin-ui/plugins/saml/__tests__/components/WebsiteSsoIdentityProviderForm.test.tsx
@@ -0,0 +1,181 @@
+import React, { act } from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import type { IdentityProvider } from 'Plugins/saml/components/hooks'
+
+const mockCreateMutate = jest.fn()
+const mockUpdateMutate = jest.fn()
+const mockNavigateBack = jest.fn()
+
+jest.mock('Plugins/saml/components/hooks', () => ({
+ useCreateIdentityProvider: () => ({
+ mutateAsync: mockCreateMutate,
+ isPending: false,
+ savedForm: false,
+ resetSavedForm: jest.fn(),
+ }),
+ useUpdateIdentityProvider: () => ({
+ mutateAsync: mockUpdateMutate,
+ isPending: false,
+ savedForm: false,
+ resetSavedForm: jest.fn(),
+ }),
+}))
+
+jest.mock('@/helpers/navigation', () => ({
+ useAppNavigation: () => ({ navigateBack: mockNavigateBack }),
+ ROUTES: { SAML_IDP_LIST: 'saml-idp-list' },
+}))
+
+jest.mock('Routes/Apps/Gluu/GluuCommitDialog', () => ({
+ __esModule: true,
+ default: () => null,
+}))
+
+import WebsiteSsoIdentityProviderForm from 'Plugins/saml/components/WebsiteSsoIdentityProviderForm'
+
+const store = configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession: true, permissions: [], config: {} }) => state,
+ cedarPermissions: (state = { permissions: {}, initialized: true, isInitializing: false }) =>
+ state,
+ noReducer: (state = {}) => state,
+ }),
+})
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+const mockConfigs: IdentityProvider = {
+ inum: 'IDP-123',
+ name: 'my-idp',
+ displayName: 'My Identity Provider',
+ description: 'A test identity provider',
+ enabled: true,
+ idpEntityId: 'https://idp.example.com/entity',
+ singleSignOnServiceUrl: 'https://idp.example.com/sso',
+ singleLogoutServiceUrl: 'https://idp.example.com/slo',
+ nameIDPolicyFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
+}
+
+const renderForm = async (props?: { configs?: IdentityProvider | null; viewOnly?: boolean }) => {
+ let result: ReturnType
+ await act(async () => {
+ result = render(, { wrapper: Wrapper })
+ })
+ await screen.findByTestId('name')
+ return result!
+}
+
+describe('WebsiteSsoIdentityProviderForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('create mode (no configs)', () => {
+ it('renders without crashing', async () => {
+ await renderForm()
+ expect(screen.getByTestId('name')).toBeInTheDocument()
+ })
+
+ it('renders an empty name field', async () => {
+ await renderForm()
+ expect(screen.getByTestId('name')).toHaveValue('')
+ })
+
+ it('renders an empty display name field', async () => {
+ await renderForm()
+ expect(screen.getByTestId('displayName')).toHaveValue('')
+ })
+
+ it('renders the idp entity id field when not importing metadata', async () => {
+ await renderForm()
+ expect(screen.getByTestId('idpEntityId')).toHaveValue('')
+ })
+
+ it('renders the footer with Apply and Cancel', async () => {
+ await renderForm()
+ expect(screen.getByText(/Apply/i)).toBeInTheDocument()
+ expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('edit mode (with configs)', () => {
+ it('populates the name field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('name')).toHaveValue('my-idp')
+ })
+
+ it('populates the display name field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('displayName')).toHaveValue('My Identity Provider')
+ })
+
+ it('populates the description field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('description')).toHaveValue('A test identity provider')
+ })
+
+ it('populates the idp entity id field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('idpEntityId')).toHaveValue('https://idp.example.com/entity')
+ })
+
+ it('populates the single sign on service url field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('singleSignOnServiceUrl')).toHaveValue(
+ 'https://idp.example.com/sso',
+ )
+ })
+
+ it('renders the enabled toggle as checked', async () => {
+ await renderForm({ configs: mockConfigs })
+ const toggle = document.querySelector('input#enabled[type="checkbox"]') as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(true)
+ })
+ })
+
+ describe('view only mode', () => {
+ it('disables the name field', async () => {
+ await renderForm({ configs: mockConfigs, viewOnly: true })
+ expect(screen.getByTestId('name')).toBeDisabled()
+ })
+
+ it('hides the Apply and Cancel buttons', async () => {
+ await renderForm({ configs: mockConfigs, viewOnly: true })
+ expect(screen.queryByText(/Apply/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/Cancel/i)).not.toBeInTheDocument()
+ })
+
+ it('still renders the Back button', async () => {
+ await renderForm({ configs: mockConfigs, viewOnly: true })
+ expect(screen.getByText(/Back/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('form interactions', () => {
+ it('allows typing in the name field', async () => {
+ await renderForm()
+ const nameInput = screen.getByTestId('name')
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: 'new-idp-name' } })
+ })
+ expect(nameInput).toHaveValue('new-idp-name')
+ })
+
+ it('allows typing in the display name field', async () => {
+ await renderForm()
+ const displayNameInput = screen.getByTestId('displayName')
+ await act(async () => {
+ fireEvent.change(displayNameInput, { target: { value: 'New Display Name' } })
+ })
+ expect(displayNameInput).toHaveValue('New Display Name')
+ })
+ })
+})
diff --git a/admin-ui/plugins/saml/__tests__/components/WebsiteSsoServiceProviderForm.test.tsx b/admin-ui/plugins/saml/__tests__/components/WebsiteSsoServiceProviderForm.test.tsx
new file mode 100644
index 000000000..c1cf3df4e
--- /dev/null
+++ b/admin-ui/plugins/saml/__tests__/components/WebsiteSsoServiceProviderForm.test.tsx
@@ -0,0 +1,207 @@
+import React, { act } from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import type { TrustRelationship } from 'Plugins/saml/components/hooks'
+
+const mockCreateMutate = jest.fn()
+const mockUpdateMutate = jest.fn()
+const mockNavigateBack = jest.fn()
+
+jest.mock('Plugins/saml/components/hooks', () => ({
+ useCreateTrustRelationship: () => ({
+ mutateAsync: mockCreateMutate,
+ isPending: false,
+ savedForm: false,
+ resetSavedForm: jest.fn(),
+ }),
+ useUpdateTrustRelationship: () => ({
+ mutateAsync: mockUpdateMutate,
+ isPending: false,
+ savedForm: false,
+ resetSavedForm: jest.fn(),
+ }),
+ TrustRelationshipSpMetaDataSourceType: { FILE: 'FILE', URL: 'URL', MANUAL: 'MANUAL' },
+}))
+
+jest.mock('@/helpers/navigation', () => ({
+ useAppNavigation: () => ({ navigateBack: mockNavigateBack }),
+ ROUTES: { SAML_SP_LIST: 'saml-sp-list' },
+}))
+
+jest.mock('JansConfigApi', () => ({
+ useGetAttributes: () => ({
+ data: {
+ entries: [
+ { dn: 'attr-dn-1', displayName: 'Email' },
+ { dn: 'attr-dn-2', displayName: 'Username' },
+ ],
+ },
+ error: null,
+ isLoading: false,
+ }),
+}))
+
+jest.mock('Routes/Apps/Gluu/GluuCommitDialog', () => ({
+ __esModule: true,
+ default: () => null,
+}))
+
+import WebsiteSsoServiceProviderForm from 'Plugins/saml/components/WebsiteSsoServiceProviderForm'
+
+const store = configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession: true, permissions: [], config: {} }) => state,
+ cedarPermissions: (state = { permissions: {}, initialized: true, isInitializing: false }) =>
+ state,
+ scopeReducer: (state = { selectedClientScopes: [] }) => state,
+ noReducer: (state = {}) => state,
+ }),
+})
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+const mockConfigs: TrustRelationship = {
+ inum: 'SP-123',
+ name: 'my-sp',
+ displayName: 'My Service Provider',
+ description: 'A test service provider',
+ enabled: true,
+ spLogoutURL: 'https://sp.example.com/logout',
+ spMetaDataSourceType: 'manual',
+ releasedAttributes: ['attr-dn-1'],
+ samlMetadata: {
+ entityId: 'https://sp.example.com/entity',
+ singleLogoutServiceUrl: 'https://sp.example.com/slo',
+ nameIDPolicyFormat: '',
+ jansAssertionConsumerServiceGetURL: '',
+ jansAssertionConsumerServicePostURL: '',
+ },
+}
+
+const renderForm = async (props?: { configs?: TrustRelationship | null; viewOnly?: boolean }) => {
+ let result: ReturnType
+ await act(async () => {
+ result = render(, { wrapper: Wrapper })
+ })
+ await screen.findByTestId('name')
+ return result!
+}
+
+describe('WebsiteSsoServiceProviderForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('create mode (no configs)', () => {
+ it('renders without crashing', async () => {
+ await renderForm()
+ expect(screen.getByTestId('name')).toBeInTheDocument()
+ })
+
+ it('renders an empty name field', async () => {
+ await renderForm()
+ expect(screen.getByTestId('name')).toHaveValue('')
+ })
+
+ it('renders an empty display name field', async () => {
+ await renderForm()
+ expect(screen.getByTestId('displayName')).toHaveValue('')
+ })
+
+ it('renders the metadata location select', async () => {
+ await renderForm()
+ expect(screen.getByTestId('spMetaDataSourceType')).toBeInTheDocument()
+ })
+
+ it('renders the footer with Apply and Cancel', async () => {
+ await renderForm()
+ expect(screen.getByText(/Apply/i)).toBeInTheDocument()
+ expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
+ })
+
+ it('does not render the manual metadata fields by default', async () => {
+ await renderForm()
+ expect(screen.queryByTestId('samlMetadata.entityId')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('edit mode (with configs)', () => {
+ it('populates the name field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('name')).toHaveValue('my-sp')
+ })
+
+ it('populates the display name field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('displayName')).toHaveValue('My Service Provider')
+ })
+
+ it('populates the description field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('description')).toHaveValue('A test service provider')
+ })
+
+ it('populates the sp logout url field', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('spLogoutURL')).toHaveValue('https://sp.example.com/logout')
+ })
+
+ it('renders the enabled toggle as checked', async () => {
+ await renderForm({ configs: mockConfigs })
+ const toggle = document.querySelector('input#enabled[type="checkbox"]') as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(true)
+ })
+
+ it('renders the manual metadata fields when source type is manual', async () => {
+ await renderForm({ configs: mockConfigs })
+ expect(screen.getByTestId('samlMetadata.entityId')).toHaveValue(
+ 'https://sp.example.com/entity',
+ )
+ })
+ })
+
+ describe('view only mode', () => {
+ it('disables the name field', async () => {
+ await renderForm({ configs: mockConfigs, viewOnly: true })
+ expect(screen.getByTestId('name')).toBeDisabled()
+ })
+
+ it('hides the Apply and Cancel buttons', async () => {
+ await renderForm({ configs: mockConfigs, viewOnly: true })
+ expect(screen.queryByText(/Apply/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/Cancel/i)).not.toBeInTheDocument()
+ })
+
+ it('still renders the Back button', async () => {
+ await renderForm({ configs: mockConfigs, viewOnly: true })
+ expect(screen.getByText(/Back/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('form interactions', () => {
+ it('allows typing in the name field', async () => {
+ await renderForm()
+ const nameInput = screen.getByTestId('name')
+ await act(async () => {
+ fireEvent.change(nameInput, { target: { value: 'new-sp-name' } })
+ })
+ expect(nameInput).toHaveValue('new-sp-name')
+ })
+
+ it('reveals the manual metadata fields when source type is changed to manual', async () => {
+ await renderForm()
+ const select = screen.getByTestId('spMetaDataSourceType') as HTMLSelectElement
+ await act(async () => {
+ fireEvent.change(select, { target: { value: 'manual' } })
+ })
+ expect(screen.getByTestId('samlMetadata.entityId')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/admin-ui/plugins/scim/__tests__/components/ScimConfiguration.test.tsx b/admin-ui/plugins/scim/__tests__/components/ScimConfiguration.test.tsx
new file mode 100644
index 000000000..6517bd3a8
--- /dev/null
+++ b/admin-ui/plugins/scim/__tests__/components/ScimConfiguration.test.tsx
@@ -0,0 +1,198 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import ScimConfiguration from 'Plugins/scim/components/ScimConfiguration'
+import type { ScimConfigurationProps, AppConfiguration3 } from 'Plugins/scim/types'
+
+jest.mock('@/cedarling', () => ({
+ useCedarling: jest.fn(() => ({
+ hasCedarReadPermission: jest.fn(() => true),
+ hasCedarWritePermission: jest.fn(() => true),
+ authorizeHelper: jest.fn(),
+ })),
+ ADMIN_UI_RESOURCES: { SCIM: 'SCIM', Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { SCIM: [], Webhooks: [] },
+}))
+
+jest.mock('@/cedarling/utility', () => ({
+ ADMIN_UI_RESOURCES: { SCIM: 'SCIM', Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { SCIM: [], Webhooks: [] },
+}))
+
+jest.mock('JansConfigApi', () => ({
+ useGetWebhooksByFeatureId: jest.fn(() => ({ data: [], isFetching: false, isFetched: true })),
+}))
+
+const store = configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { permissions: [] }) => state,
+ webhookReducer: (
+ state = {
+ featureWebhooks: [],
+ loadingWebhooks: false,
+ webhookModal: false,
+ triggerWebhookInProgress: false,
+ },
+ ) => state,
+ noReducer: (state = {}) => state,
+ }),
+})
+
+const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+)
+
+const mockClasses: ScimConfigurationProps['classes'] = {
+ formSection: 'formSection',
+ fieldsGrid: 'fieldsGrid',
+ formLabels: 'formLabels',
+ formWithInputs: 'formWithInputs',
+ fieldItem: 'fieldItem',
+ fieldItemFullWidth: 'fieldItemFullWidth',
+}
+
+const mockConfig: AppConfiguration3 = {
+ baseDN: 'ou=scim,o=gluu',
+ applicationUrl: 'https://example.com',
+ baseEndpoint: 'https://example.com/scim',
+ personCustomObjectClass: 'gluuCustomPerson',
+ oxAuthIssuer: 'https://example.com',
+ protectionMode: 'OAUTH',
+ maxCount: 200,
+ bulkMaxOperations: 30,
+ bulkMaxPayloadSize: 3145728,
+ loggingLevel: 'INFO',
+ loggingLayout: 'text',
+ metricReporterEnabled: true,
+ metricReporterInterval: 300,
+ metricReporterKeepDataDays: 15,
+ useLocalCache: false,
+}
+
+const mockHandleSubmit = jest.fn()
+
+const defaultProps: ScimConfigurationProps = {
+ scimConfiguration: mockConfig,
+ handleSubmit: mockHandleSubmit,
+ isSubmitting: false,
+ canWriteScim: true,
+ classes: mockClasses,
+}
+
+describe('ScimConfiguration', () => {
+ beforeEach(() => {
+ mockHandleSubmit.mockClear()
+ })
+
+ it('renders the form without crashing', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('form')).toBeInTheDocument()
+ })
+
+ it('populates fields from the supplied configuration', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('input[name="baseDN"]')).toHaveValue('ou=scim,o=gluu')
+ expect(document.querySelector('input[name="applicationUrl"]')).toHaveValue(
+ 'https://example.com',
+ )
+ })
+
+ it('renders the disabled baseDN field', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('input[name="baseDN"]')).toBeDisabled()
+ })
+
+ it('renders select fields for protection mode and logging level', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('select[name="protectionMode"]')).toBeInTheDocument()
+ expect(document.querySelector('select[name="loggingLevel"]')).toBeInTheDocument()
+ })
+
+ it('renders the Apply submit button when canWriteScim is true', () => {
+ render(
+
+
+ ,
+ )
+
+ const applyButton = document.querySelector('button[type="submit"]')
+ expect(applyButton).toBeTruthy()
+ expect(applyButton).toHaveAttribute('title', 'Apply')
+ })
+
+ it('hides the Apply button when canWriteScim is false', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('button[type="submit"]')).toBeNull()
+ })
+
+ it('reflects edits to an editable field', async () => {
+ render(
+
+
+ ,
+ )
+
+ const appUrl = document.querySelector('input[name="applicationUrl"]') as HTMLInputElement
+ fireEvent.change(appUrl, { target: { value: 'https://updated.example.com' } })
+
+ await waitFor(() => {
+ expect(appUrl.value).toBe('https://updated.example.com')
+ })
+ })
+
+ it('renders without crashing when configuration is undefined', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('form')).toBeInTheDocument()
+ expect(document.querySelector('input[name="baseDN"]')).toHaveValue('')
+ })
+
+ it('disables Apply and Cancel while the form is pristine', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('button[type="submit"]')).toBeDisabled()
+ expect(screen.getByTitle('Cancel').closest('button')).toBeDisabled()
+ })
+})
diff --git a/admin-ui/plugins/scim/__tests__/components/ScimFieldRenderer.test.tsx b/admin-ui/plugins/scim/__tests__/components/ScimFieldRenderer.test.tsx
new file mode 100644
index 000000000..12949a792
--- /dev/null
+++ b/admin-ui/plugins/scim/__tests__/components/ScimFieldRenderer.test.tsx
@@ -0,0 +1,291 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import ScimFieldRenderer from 'Plugins/scim/components/ScimFieldRenderer'
+import type { FieldConfig, ScimFormValues } from 'Plugins/scim/types'
+import type { FormikProps } from 'formik'
+
+jest.mock('@/cedarling', () => ({
+ useCedarling: jest.fn(() => ({
+ hasCedarReadPermission: jest.fn(() => true),
+ hasCedarWritePermission: jest.fn(() => true),
+ authorizeHelper: jest.fn(),
+ })),
+}))
+
+jest.mock('@/cedarling/utility', () => ({
+ ADMIN_UI_RESOURCES: { SCIM: 'SCIM' },
+ CEDAR_RESOURCE_SCOPES: { SCIM: [] },
+}))
+
+const store = configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { permissions: [] }) => state,
+ noReducer: (state = {}) => state,
+ }),
+})
+
+const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+)
+
+const defaultFormValues: ScimFormValues = {
+ baseDN: '',
+ applicationUrl: '',
+ baseEndpoint: '',
+ personCustomObjectClass: '',
+ oxAuthIssuer: '',
+ protectionMode: 'OAUTH',
+ maxCount: 200,
+ bulkMaxOperations: 30,
+ bulkMaxPayloadSize: 3145728,
+ userExtensionSchemaURI: '',
+ loggingLevel: 'INFO',
+ loggingLayout: 'text',
+ externalLoggerConfiguration: '',
+ disableExternalLoggerConfiguration: false,
+ metricReporterInterval: 300,
+ metricReporterKeepDataDays: 15,
+ metricReporterEnabled: true,
+ disableJdkLogger: false,
+ disableLoggerTimer: false,
+ useLocalCache: false,
+ skipDefinedPasswordValidation: false,
+}
+
+const createMockFormik = (
+ overrides: Partial> = {},
+): FormikProps => ({
+ values: defaultFormValues,
+ initialValues: defaultFormValues,
+ initialErrors: {},
+ initialTouched: {},
+ initialStatus: undefined,
+ errors: {},
+ touched: {},
+ isSubmitting: false,
+ isValidating: false,
+ submitCount: 0,
+ dirty: false,
+ isValid: true,
+ status: undefined,
+ handleChange: jest.fn(),
+ handleBlur: jest.fn(),
+ handleSubmit: jest.fn(),
+ handleReset: jest.fn(),
+ setFieldValue: jest.fn(),
+ setFieldTouched: jest.fn(),
+ setFieldError: jest.fn(),
+ setValues: jest.fn(),
+ setErrors: jest.fn(),
+ setTouched: jest.fn(),
+ setStatus: jest.fn(),
+ setSubmitting: jest.fn(),
+ setFormikState: jest.fn(),
+ validateField: jest.fn(),
+ validateForm: jest.fn().mockResolvedValue({}),
+ resetForm: jest.fn(),
+ submitForm: jest.fn().mockResolvedValue(undefined),
+ getFieldProps: jest
+ .fn()
+ .mockReturnValue({ value: '', onChange: jest.fn(), onBlur: jest.fn(), name: '' }),
+ getFieldMeta: jest.fn().mockReturnValue({
+ value: '',
+ touched: false,
+ error: undefined,
+ initialValue: '',
+ initialTouched: false,
+ initialError: undefined,
+ }),
+ getFieldHelpers: jest
+ .fn()
+ .mockReturnValue({ setValue: jest.fn(), setTouched: jest.fn(), setError: jest.fn() }),
+ registerField: jest.fn(),
+ unregisterField: jest.fn(),
+ ...overrides,
+})
+
+const defaultProps = {
+ fieldItemClass: 'field-item',
+ fieldItemFullWidthClass: 'field-item-full',
+}
+
+describe('ScimFieldRenderer', () => {
+ it('renders a text field', () => {
+ const config: FieldConfig = {
+ name: 'applicationUrl',
+ label: 'fields.application_url',
+ type: 'text',
+ colSize: 6,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('input[name="applicationUrl"]')).toBeInTheDocument()
+ })
+
+ it('renders a number field', () => {
+ const config: FieldConfig = {
+ name: 'maxCount',
+ label: 'fields.max_count',
+ type: 'number',
+ colSize: 6,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ const input = document.querySelector('input[name="maxCount"]')
+ expect(input).toBeInTheDocument()
+ expect(input).toHaveAttribute('type', 'number')
+ })
+
+ it('renders a select field', () => {
+ const config: FieldConfig = {
+ name: 'loggingLevel',
+ label: 'fields.logging_level',
+ type: 'select',
+ selectOptions: ['INFO', 'DEBUG', 'ERROR'],
+ colSize: 6,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('select[name="loggingLevel"]')).toBeInTheDocument()
+ })
+
+ it('renders a toggle field', () => {
+ const config: FieldConfig = {
+ name: 'useLocalCache',
+ label: 'fields.use_local_cache',
+ type: 'toggle',
+ colSize: 6,
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.querySelector('input[type="checkbox"]')).toBeInTheDocument()
+ })
+
+ it('disables the field when config.disabled is true', () => {
+ const config: FieldConfig = {
+ name: 'baseDN',
+ label: 'fields.base_dn',
+ type: 'text',
+ disabled: true,
+ colSize: 12,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('input[name="baseDN"]')).toBeDisabled()
+ })
+
+ it('populates the field value from formik values', () => {
+ const config: FieldConfig = {
+ name: 'applicationUrl',
+ label: 'fields.application_url',
+ type: 'text',
+ colSize: 6,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('input[name="applicationUrl"]')).toHaveValue(
+ 'https://example.com',
+ )
+ })
+
+ it('uses the full-width class when colSize is 12', () => {
+ const config: FieldConfig = {
+ name: 'baseDN',
+ label: 'fields.base_dn',
+ type: 'text',
+ colSize: 12,
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.querySelector('.field-item-full')).toBeInTheDocument()
+ })
+
+ it('uses the regular class when colSize is not 12', () => {
+ const config: FieldConfig = {
+ name: 'applicationUrl',
+ label: 'fields.application_url',
+ type: 'text',
+ colSize: 6,
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.querySelector('.field-item')).toBeInTheDocument()
+ })
+
+ it('returns null for an unrecognized field type', () => {
+ const config: Omit & { type: string } = {
+ name: 'applicationUrl',
+ label: 'fields.application_url',
+ type: 'unrecognized',
+ colSize: 6,
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.firstChild).toBeNull()
+ })
+})
diff --git a/admin-ui/plugins/scripts/__tests__/components/CustomScriptListPage.test.tsx b/admin-ui/plugins/scripts/__tests__/components/CustomScriptListPage.test.tsx
new file mode 100644
index 000000000..7ad04ca42
--- /dev/null
+++ b/admin-ui/plugins/scripts/__tests__/components/CustomScriptListPage.test.tsx
@@ -0,0 +1,191 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import type { CustomScript } from 'JansConfigApi'
+
+const mockScripts: CustomScript[] = [
+ {
+ name: 'basic',
+ scriptType: 'person_authentication',
+ programmingLanguage: 'python',
+ level: 10,
+ dn: 'inum=A51E-76DA,ou=scripts,o=jans',
+ inum: 'A51E-76DA',
+ description: 'Sample authentication module',
+ revision: 1,
+ enabled: true,
+ internal: false,
+ },
+ {
+ name: 'introspection_script',
+ scriptType: 'introspection',
+ programmingLanguage: 'java',
+ level: 1,
+ dn: 'inum=B72F-91AB,ou=scripts,o=jans',
+ inum: 'B72F-91AB',
+ description: 'Introspection sample',
+ revision: 2,
+ enabled: false,
+ internal: false,
+ },
+]
+
+const mockNavigateToRoute = jest.fn()
+const mockDeleteMutateAsync = jest.fn()
+
+const mockUseCustomScriptsByType = jest.fn(() => ({
+ data: {
+ entries: mockScripts,
+ totalEntriesCount: mockScripts.length,
+ },
+ isLoading: false,
+ refetch: jest.fn(),
+}))
+
+jest.mock('Plugins/scripts/components/hooks', () => ({
+ useCustomScriptsByType: () => mockUseCustomScriptsByType(),
+ useCustomScriptTypes: jest.fn(() => ({
+ data: [
+ { value: 'person_authentication', name: 'Person Authentication' },
+ { value: 'introspection', name: 'Introspection' },
+ ],
+ isLoading: false,
+ })),
+ useDeleteCustomScript: jest.fn(() => ({
+ mutateAsync: mockDeleteMutateAsync,
+ isPending: false,
+ })),
+}))
+
+jest.mock('@/helpers/navigation', () => ({
+ ...jest.requireActual('@/helpers/navigation'),
+ useAppNavigation: () => ({ navigateToRoute: mockNavigateToRoute }),
+}))
+
+jest.mock('@/cedarling', () => ({
+ useCedarling: jest.fn(() => ({
+ hasCedarReadPermission: jest.fn(() => true),
+ hasCedarWritePermission: jest.fn(() => true),
+ hasCedarDeletePermission: jest.fn(() => true),
+ authorizeHelper: jest.fn(),
+ })),
+ ADMIN_UI_RESOURCES: { Scripts: 'Scripts', Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { Scripts: [], Webhooks: [] },
+}))
+
+jest.mock('@/cedarling/utility', () => ({
+ ADMIN_UI_RESOURCES: { Scripts: 'Scripts', Webhooks: 'Webhooks' },
+ CEDAR_RESOURCE_SCOPES: { Scripts: [], Webhooks: [] },
+}))
+
+jest.mock('JansConfigApi', () => ({
+ useGetWebhooksByFeatureId: jest.fn(() => ({
+ data: [],
+ isLoading: false,
+ isFetching: false,
+ isFetched: true,
+ })),
+}))
+
+import CustomScriptListPage from 'Plugins/scripts/components/CustomScriptListPage'
+
+const store = configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession: true, permissions: [] }) => state,
+ webhookReducer: (
+ state = {
+ featureWebhooks: [],
+ loadingWebhooks: false,
+ webhookModal: false,
+ triggerWebhookInProgress: false,
+ },
+ ) => state,
+ noReducer: (state = {}) => state,
+ }),
+})
+
+const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+)
+
+describe('CustomScriptListPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseCustomScriptsByType.mockReturnValue({
+ data: {
+ entries: mockScripts,
+ totalEntriesCount: mockScripts.length,
+ },
+ isLoading: false,
+ refetch: jest.fn(),
+ })
+ })
+
+ it('renders without crashing and shows script rows', async () => {
+ render(, { wrapper: Wrapper })
+ expect(await screen.findByText('basic')).toBeInTheDocument()
+ expect(screen.getByText('introspection_script')).toBeInTheDocument()
+ })
+
+ it('renders the column headers', async () => {
+ render(, { wrapper: Wrapper })
+ await screen.findByText('basic')
+ expect(screen.getAllByText(/inum/i).length).toBeGreaterThanOrEqual(1)
+ expect(screen.getAllByText(/Name/i).length).toBeGreaterThanOrEqual(1)
+ expect(screen.getAllByText(/Description/i).length).toBeGreaterThanOrEqual(1)
+ expect(screen.getAllByText(/Enabled/i).length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('renders the script descriptions', async () => {
+ render(, { wrapper: Wrapper })
+ expect(await screen.findByText('Sample authentication module')).toBeInTheDocument()
+ })
+
+ it('renders the search toolbar with placeholder', async () => {
+ render(, { wrapper: Wrapper })
+ await screen.findByText('basic')
+ expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument()
+ })
+
+ it('renders the add script button', async () => {
+ render(, { wrapper: Wrapper })
+ await screen.findByText('basic')
+ expect(screen.getByText(/Add Custom Script/i)).toBeInTheDocument()
+ })
+
+ it('renders action icons for edit, view, and delete', async () => {
+ render(, { wrapper: Wrapper })
+ await screen.findByText('basic')
+ expect(screen.getAllByTestId('EditIcon')[0]).toBeInTheDocument()
+ expect(screen.getAllByTestId('VisibilityOutlinedIcon')[0]).toBeInTheDocument()
+ expect(screen.getAllByTestId('DeleteOutlinedIcon')[0]).toBeInTheDocument()
+ })
+
+ it('navigates to add page when the add button is clicked', async () => {
+ render(, { wrapper: Wrapper })
+ await screen.findByText('basic')
+ fireEvent.click(screen.getByText(/Add Custom Script/i))
+ await waitFor(() => {
+ expect(mockNavigateToRoute).toHaveBeenCalled()
+ })
+ })
+
+ it('renders the empty state when there are no scripts', async () => {
+ mockUseCustomScriptsByType.mockReturnValue({
+ data: { entries: [], totalEntriesCount: 0 },
+ isLoading: false,
+ refetch: jest.fn(),
+ })
+ render(, { wrapper: Wrapper })
+ expect(await screen.findByText(/no scripts found/i)).toBeInTheDocument()
+ })
+})
diff --git a/admin-ui/plugins/services/__tests__/components/CacheInMemory.test.tsx b/admin-ui/plugins/services/__tests__/components/CacheInMemory.test.tsx
new file mode 100644
index 000000000..328026389
--- /dev/null
+++ b/admin-ui/plugins/services/__tests__/components/CacheInMemory.test.tsx
@@ -0,0 +1,57 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { useFormik } from 'formik'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import CacheInMemory from 'Plugins/services/Components/CacheInMemory'
+import type { CacheFormValues } from 'Plugins/services/Components/types'
+
+const classes: Record = {
+ sectionGrid: 'sectionGrid',
+ fieldItem: 'fieldItem',
+}
+
+const mockInMemoryConfig: CacheFormValues = {
+ cacheProviderType: 'IN_MEMORY',
+ memoryDefaultPutExpiration: 60,
+}
+
+type HarnessProps = {
+ disabled?: boolean
+}
+
+const Harness = ({ disabled }: HarnessProps) => {
+ const formik = useFormik({
+ initialValues: mockInMemoryConfig,
+ onSubmit: () => {},
+ })
+ return
+}
+
+const renderHarness = (props: HarnessProps = {}) =>
+ render(
+
+
+ ,
+ )
+
+describe('CacheInMemory', () => {
+ it('renders the default put expiration field', () => {
+ renderHarness()
+ expect(screen.getByTestId('memoryDefaultPutExpiration')).toBeInTheDocument()
+ })
+
+ it('populates the field value from formik initialValues', () => {
+ renderHarness()
+ expect(screen.getByTestId('memoryDefaultPutExpiration')).toHaveValue(60)
+ })
+
+ it('renders the field enabled when not disabled', () => {
+ renderHarness()
+ expect(screen.getByTestId('memoryDefaultPutExpiration')).not.toBeDisabled()
+ })
+
+ it('disables the field when disabled is true', () => {
+ renderHarness({ disabled: true })
+ expect(screen.getByTestId('memoryDefaultPutExpiration')).toBeDisabled()
+ })
+})
diff --git a/admin-ui/plugins/services/__tests__/components/CacheMemcached.test.tsx b/admin-ui/plugins/services/__tests__/components/CacheMemcached.test.tsx
new file mode 100644
index 000000000..3854495a3
--- /dev/null
+++ b/admin-ui/plugins/services/__tests__/components/CacheMemcached.test.tsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { useFormik } from 'formik'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import CacheMemcached from 'Plugins/services/Components/CacheMemcached'
+import type { CacheFormValues } from 'Plugins/services/Components/types'
+
+const classes: Record = {
+ sectionGrid: 'sectionGrid',
+ fieldItem: 'fieldItem',
+}
+
+const mockMemcachedConfig: CacheFormValues = {
+ cacheProviderType: 'MEMCACHED',
+ memCacheServers: 'localhost:11211',
+ maxOperationQueueLength: 100000,
+ bufferSize: 32768,
+ memDefaultPutExpiration: 60,
+ connectionFactoryType: 'DEFAULT',
+}
+
+type HarnessProps = {
+ disabled?: boolean
+}
+
+const Harness = ({ disabled }: HarnessProps) => {
+ const formik = useFormik({
+ initialValues: mockMemcachedConfig,
+ onSubmit: () => {},
+ })
+ return
+}
+
+const renderHarness = (props: HarnessProps = {}) =>
+ render(
+
+
+ ,
+ )
+
+describe('CacheMemcached', () => {
+ it('renders its key fields', () => {
+ renderHarness()
+ expect(screen.getByTestId('memCacheServers')).toBeInTheDocument()
+ expect(screen.getByTestId('maxOperationQueueLength')).toBeInTheDocument()
+ expect(screen.getByTestId('bufferSize')).toBeInTheDocument()
+ expect(document.querySelector('select[name="connectionFactoryType"]')).toBeInTheDocument()
+ })
+
+ it('populates field values from formik initialValues', () => {
+ renderHarness()
+ expect(screen.getByTestId('memCacheServers')).toHaveValue('localhost:11211')
+ expect(screen.getByTestId('maxOperationQueueLength')).toHaveValue(100000)
+ expect(screen.getByTestId('memDefaultPutExpiration')).toHaveValue(60)
+ })
+
+ it('renders fields enabled when not disabled', () => {
+ renderHarness()
+ expect(screen.getByTestId('memCacheServers')).not.toBeDisabled()
+ })
+
+ it('disables fields when disabled is true', () => {
+ renderHarness({ disabled: true })
+ expect(screen.getByTestId('memCacheServers')).toBeDisabled()
+ expect(screen.getByTestId('bufferSize')).toBeDisabled()
+ })
+})
diff --git a/admin-ui/plugins/services/__tests__/components/CacheNative.test.tsx b/admin-ui/plugins/services/__tests__/components/CacheNative.test.tsx
new file mode 100644
index 000000000..8d1655997
--- /dev/null
+++ b/admin-ui/plugins/services/__tests__/components/CacheNative.test.tsx
@@ -0,0 +1,71 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { useFormik } from 'formik'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import CacheNative from 'Plugins/services/Components/CacheNative'
+import type { CacheFormValues } from 'Plugins/services/Components/types'
+
+const classes: Record = {
+ sectionGrid: 'sectionGrid',
+ fieldItem: 'fieldItem',
+}
+
+const mockNativeConfig: CacheFormValues = {
+ cacheProviderType: 'NATIVE_PERSISTENCE',
+ nativeDefaultPutExpiration: 60,
+ defaultCleanupBatchSize: 25,
+ deleteExpiredOnGetRequest: true,
+}
+
+type HarnessProps = {
+ disabled?: boolean
+}
+
+const Harness = ({ disabled }: HarnessProps) => {
+ const formik = useFormik({
+ initialValues: mockNativeConfig,
+ onSubmit: () => {},
+ })
+ return
+}
+
+const renderHarness = (props: HarnessProps = {}) =>
+ render(
+
+
+ ,
+ )
+
+describe('CacheNative', () => {
+ it('renders its key fields', () => {
+ renderHarness()
+ expect(screen.getByTestId('nativeDefaultPutExpiration')).toBeInTheDocument()
+ expect(screen.getByTestId('defaultCleanupBatchSize')).toBeInTheDocument()
+ })
+
+ it('populates field values from formik initialValues', () => {
+ renderHarness()
+ expect(screen.getByTestId('nativeDefaultPutExpiration')).toHaveValue(60)
+ expect(screen.getByTestId('defaultCleanupBatchSize')).toHaveValue(25)
+ })
+
+ it('renders the deleteExpiredOnGetRequest toggle as checked from formik values', () => {
+ renderHarness()
+ const toggle = document.querySelector(
+ 'input#deleteExpiredOnGetRequest[type="checkbox"]',
+ ) as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(true)
+ })
+
+ it('renders fields enabled when not disabled', () => {
+ renderHarness()
+ expect(screen.getByTestId('nativeDefaultPutExpiration')).not.toBeDisabled()
+ })
+
+ it('disables fields when disabled is true', () => {
+ renderHarness({ disabled: true })
+ expect(screen.getByTestId('nativeDefaultPutExpiration')).toBeDisabled()
+ expect(screen.getByTestId('defaultCleanupBatchSize')).toBeDisabled()
+ })
+})
diff --git a/admin-ui/plugins/services/__tests__/components/CacheRedis.test.tsx b/admin-ui/plugins/services/__tests__/components/CacheRedis.test.tsx
new file mode 100644
index 000000000..e2a17994d
--- /dev/null
+++ b/admin-ui/plugins/services/__tests__/components/CacheRedis.test.tsx
@@ -0,0 +1,81 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { useFormik } from 'formik'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+import CacheRedis from 'Plugins/services/Components/CacheRedis'
+import type { CacheFormValues } from 'Plugins/services/Components/types'
+
+const classes: Record = {
+ sectionGrid: 'sectionGrid',
+ fieldItem: 'fieldItem',
+}
+
+const mockRedisConfig: CacheFormValues = {
+ cacheProviderType: 'REDIS',
+ redisProviderType: 'STANDALONE',
+ servers: 'localhost:6379',
+ password: 'secret',
+ sentinelMasterGroupName: 'masterGroup',
+ sslTrustStoreFilePath: '/etc/certs/redis.jks',
+ redisDefaultPutExpiration: 60,
+ useSSL: true,
+ maxIdleConnections: 10,
+ maxTotalConnections: 500,
+ connectionTimeout: 3000,
+ soTimeout: 3000,
+ maxRetryAttempts: 5,
+}
+
+type HarnessProps = {
+ disabled?: boolean
+}
+
+const Harness = ({ disabled }: HarnessProps) => {
+ const formik = useFormik({
+ initialValues: mockRedisConfig,
+ onSubmit: () => {},
+ })
+ return
+}
+
+const renderHarness = (props: HarnessProps = {}) =>
+ render(
+
+
+ ,
+ )
+
+describe('CacheRedis', () => {
+ it('renders its key fields', () => {
+ renderHarness()
+ expect(screen.getByTestId('servers')).toBeInTheDocument()
+ expect(screen.getByTestId('password')).toBeInTheDocument()
+ expect(screen.getByTestId('maxRetryAttempts')).toBeInTheDocument()
+ expect(document.querySelector('select[name="redisProviderType"]')).toBeInTheDocument()
+ })
+
+ it('populates field values from formik initialValues', () => {
+ renderHarness()
+ expect(screen.getByTestId('servers')).toHaveValue('localhost:6379')
+ expect(screen.getByTestId('password')).toHaveValue('secret')
+ expect(screen.getByTestId('maxRetryAttempts')).toHaveValue(5)
+ })
+
+ it('renders the useSSL toggle as checked from formik values', () => {
+ renderHarness()
+ const toggle = document.querySelector('input#useSSL[type="checkbox"]') as HTMLInputElement
+ expect(toggle).toBeTruthy()
+ expect(toggle.checked).toBe(true)
+ })
+
+ it('renders fields enabled when not disabled', () => {
+ renderHarness()
+ expect(screen.getByTestId('servers')).not.toBeDisabled()
+ })
+
+ it('disables fields when disabled is true', () => {
+ renderHarness({ disabled: true })
+ expect(screen.getByTestId('servers')).toBeDisabled()
+ expect(screen.getByTestId('password')).toBeDisabled()
+ })
+})
diff --git a/admin-ui/plugins/services/__tests__/components/hooks/useCacheAudit.test.ts b/admin-ui/plugins/services/__tests__/components/hooks/useCacheAudit.test.ts
new file mode 100644
index 000000000..9d76f5a9b
--- /dev/null
+++ b/admin-ui/plugins/services/__tests__/components/hooks/useCacheAudit.test.ts
@@ -0,0 +1,92 @@
+import { renderHook, act } from '@testing-library/react'
+import type { CacheConfiguration } from 'JansConfigApi'
+import type { LogAuditParams } from 'Utils/AuditLogger'
+import { useCacheAudit } from 'Plugins/services/Components/hooks/useCacheAudit'
+
+const mockLogAuditUserAction = jest.fn()
+const mockLoggerError = jest.fn()
+
+jest.mock('Utils/AuditLogger', () => ({
+ logAuditUserAction: (params: LogAuditParams) => mockLogAuditUserAction(params),
+}))
+
+jest.mock('@/audit', () => ({
+ useAuditContext: jest.fn(() => ({
+ client_id: 'test-client-id',
+ userinfo: { inum: 'inum-123', name: 'admin' },
+ })),
+ PATCH: 'PATCH',
+}))
+
+jest.mock('@/utils/logger', () => ({
+ logger: {
+ error: (message: string, detail: Error | string) => mockLoggerError(message, detail),
+ },
+}))
+
+const mockCache: CacheConfiguration = {
+ cacheProviderType: 'REDIS',
+}
+
+describe('useCacheAudit', () => {
+ beforeEach(() => {
+ mockLogAuditUserAction.mockReset()
+ mockLoggerError.mockReset()
+ mockLogAuditUserAction.mockResolvedValue(undefined)
+ })
+
+ it('returns logCacheUpdate function', () => {
+ const { result } = renderHook(() => useCacheAudit())
+ expect(typeof result.current.logCacheUpdate).toBe('function')
+ })
+
+ it('calls logAuditUserAction with the expected audit shape', async () => {
+ const { result } = renderHook(() => useCacheAudit())
+
+ await act(async () => {
+ await result.current.logCacheUpdate(mockCache, 'Updated cache config', {
+ servers: 'localhost:6379',
+ })
+ })
+
+ expect(mockLogAuditUserAction).toHaveBeenCalledTimes(1)
+ expect(mockLogAuditUserAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'PATCH',
+ resource: 'api-cache',
+ message: 'Updated cache config',
+ modifiedFields: { servers: 'localhost:6379' },
+ performedOn: 'REDIS',
+ client_id: 'test-client-id',
+ payload: mockCache,
+ userinfo: { inum: 'inum-123', name: 'admin' },
+ }),
+ )
+ })
+
+ it('passes performedOn from cache.cacheProviderType', async () => {
+ const { result } = renderHook(() => useCacheAudit())
+ const inMemoryCache: CacheConfiguration = { cacheProviderType: 'IN_MEMORY' }
+
+ await act(async () => {
+ await result.current.logCacheUpdate(inMemoryCache, 'msg')
+ })
+
+ expect(mockLogAuditUserAction).toHaveBeenCalledWith(
+ expect.objectContaining({ performedOn: 'IN_MEMORY' }),
+ )
+ })
+
+ it('logs an error and does not throw when logAuditUserAction rejects', async () => {
+ const failure = new Error('audit failed')
+ mockLogAuditUserAction.mockRejectedValueOnce(failure)
+ const { result } = renderHook(() => useCacheAudit())
+
+ await act(async () => {
+ await expect(result.current.logCacheUpdate(mockCache, 'msg')).resolves.toBeUndefined()
+ })
+
+ expect(mockLoggerError).toHaveBeenCalledTimes(1)
+ expect(mockLoggerError).toHaveBeenCalledWith('Failed to log cache update audit:', failure)
+ })
+})
diff --git a/admin-ui/plugins/user-claims/__tests__/components/UserClaimsViewPage.test.tsx b/admin-ui/plugins/user-claims/__tests__/components/UserClaimsViewPage.test.tsx
new file mode 100644
index 000000000..a841bec72
--- /dev/null
+++ b/admin-ui/plugins/user-claims/__tests__/components/UserClaimsViewPage.test.tsx
@@ -0,0 +1,172 @@
+import React, { act } from 'react'
+import { render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper'
+
+type AttributeData = {
+ inum: string
+ name: string
+ displayName: string
+ description: string
+ status: string
+ dataType: string
+ editType: string[]
+ viewType: string[]
+ usageType: string[]
+ oxMultiValuedAttribute: boolean
+ jansHideOnDiscovery: boolean
+}
+
+type AttributeQueryResult = {
+ data: AttributeData | undefined
+ isLoading: boolean
+ error: Error | null
+}
+
+const mockAttribute: AttributeData = {
+ inum: 'B4B0',
+ name: 'givenName',
+ displayName: 'Given Name',
+ description: 'The given name attribute',
+ status: 'active',
+ dataType: 'string',
+ editType: ['admin'],
+ viewType: ['admin'],
+ usageType: [],
+ oxMultiValuedAttribute: false,
+ jansHideOnDiscovery: false,
+}
+
+const mockUseAttribute = jest.fn(() => ({
+ data: mockAttribute,
+ isLoading: false,
+ error: null,
+}))
+
+jest.mock('../../hooks', () => ({
+ useAttribute: (inum: string) => mockUseAttribute(inum),
+}))
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({ gid: 'B4B0' }),
+}))
+
+jest.mock('@/cedarling', () => {
+ const { ADMIN_UI_RESOURCES, CEDAR_RESOURCE_SCOPES } = jest.requireActual('../cedarTestHelpers')
+ return {
+ useCedarling: jest.fn(() => ({
+ hasCedarReadPermission: jest.fn(() => true),
+ hasCedarWritePermission: jest.fn(() => true),
+ hasCedarDeletePermission: jest.fn(() => true),
+ authorizeHelper: jest.fn(),
+ })),
+ ADMIN_UI_RESOURCES,
+ CEDAR_RESOURCE_SCOPES,
+ }
+})
+
+jest.mock('@/cedarling/utility', () => {
+ const { ADMIN_UI_RESOURCES, CEDAR_RESOURCE_SCOPES } = jest.requireActual('../cedarTestHelpers')
+ return { ADMIN_UI_RESOURCES, CEDAR_RESOURCE_SCOPES }
+})
+
+jest.mock('JansConfigApi', () => ({
+ getGetAttributesQueryKey: jest.fn(() => ['attributes']),
+ useGetWebhooksByFeatureId: jest.fn(() => ({
+ data: [],
+ isLoading: false,
+ isFetching: false,
+ isFetched: true,
+ })),
+}))
+
+import UserClaimsViewPage from 'Plugins/user-claims/components/UserClaimsViewPage'
+
+const store = configureStore({
+ reducer: combineReducers({
+ authReducer: (state = { hasSession: true, permissions: [] }) => state,
+ webhookReducer: (
+ state = {
+ featureWebhooks: [],
+ loadingWebhooks: false,
+ webhookModal: false,
+ triggerWebhookInProgress: false,
+ },
+ ) => state,
+ cedarPermissions: (state = { permissions: [] }) => state,
+ noReducer: (state = {}) => state,
+ }),
+})
+
+const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+)
+
+const renderPage = async () => {
+ let result: ReturnType
+ await act(async () => {
+ result = render(, { wrapper: Wrapper })
+ })
+ return result!
+}
+
+describe('UserClaimsViewPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseAttribute.mockReturnValue({
+ data: mockAttribute,
+ isLoading: false,
+ error: null,
+ })
+ })
+
+ it('renders without crashing and shows the form', async () => {
+ await renderPage()
+ expect(await screen.findByTestId('name')).toBeInTheDocument()
+ })
+
+ it('populates the name field with the loaded attribute value', async () => {
+ await renderPage()
+ expect(await screen.findByTestId('name')).toHaveValue('givenName')
+ })
+
+ it('populates the display name field with the loaded attribute value', async () => {
+ await renderPage()
+ expect(await screen.findByTestId('displayName')).toHaveValue('Given Name')
+ })
+
+ it('populates the description field with the loaded attribute value', async () => {
+ await renderPage()
+ expect(await screen.findByTestId('description')).toHaveValue('The given name attribute')
+ })
+
+ it('hides the save/apply button in view mode', async () => {
+ await renderPage()
+ await screen.findByTestId('name')
+ expect(screen.queryByText(/Apply/i)).not.toBeInTheDocument()
+ })
+
+ it('renders the Back button in view mode', async () => {
+ await renderPage()
+ expect(await screen.findByText(/Back/i)).toBeInTheDocument()
+ })
+
+ it('renders an error alert when the attribute query fails', async () => {
+ mockUseAttribute.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ error: new Error('boom'),
+ })
+ await renderPage()
+ expect(await screen.findByRole('alert')).toBeInTheDocument()
+ })
+})
diff --git a/admin-ui/plugins/user-management/__tests__/components/UserDetailViewPage.test.tsx b/admin-ui/plugins/user-management/__tests__/components/UserDetailViewPage.test.tsx
new file mode 100644
index 000000000..c57239f89
--- /dev/null
+++ b/admin-ui/plugins/user-management/__tests__/components/UserDetailViewPage.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen } from '@testing-library/react'
+import {
+ createUserManagementTestStore,
+ createUserManagementQueryClient,
+ createUserManagementTestWrapper,
+} from 'Plugins/user-management/__tests__/helpers/userManagementTestUtils'
+import UserDetailViewPage from 'Plugins/user-management/components/UserDetailViewPage'
+import type { RowProps } from 'Plugins/user-management/types/UserApiTypes'
+
+const store = createUserManagementTestStore()
+const queryClient = createUserManagementQueryClient()
+const Wrapper = createUserManagementTestWrapper(store, queryClient)
+
+const baseRow: RowProps['row'] = {
+ rowData: {
+ displayName: 'Test User',
+ mail: 'test@example.com',
+ givenName: 'Test',
+ userId: 'testuser',
+ customAttributes: [
+ { name: 'jansAdminUIRole', values: ['api-admin', 'cb-admin'] },
+ { name: 'sn', values: ['Doe'] },
+ ],
+ },
+}
+
+describe('UserDetailViewPage', () => {
+ afterEach(() => {
+ queryClient.clear()
+ })
+
+ it('renders without crashing', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('Name:')).toBeInTheDocument()
+ })
+
+ it('renders all field labels', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('Name:')).toBeInTheDocument()
+ expect(screen.getByText('Email:')).toBeInTheDocument()
+ expect(screen.getByText('Given Name:')).toBeInTheDocument()
+ expect(screen.getByText('Jans Admin UI Role:')).toBeInTheDocument()
+ expect(screen.getByText('User Name:')).toBeInTheDocument()
+ expect(screen.getByText('Last Name:')).toBeInTheDocument()
+ })
+
+ it('populates field values from rowData', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('Test User')).toBeInTheDocument()
+ expect(screen.getByText('test@example.com')).toBeInTheDocument()
+ expect(screen.getByText('Test')).toBeInTheDocument()
+ expect(screen.getByText('testuser')).toBeInTheDocument()
+ })
+
+ it('joins multiple admin UI role values with comma', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('api-admin, cb-admin')).toBeInTheDocument()
+ })
+
+ it('renders em dash placeholder for empty values', () => {
+ const emptyRow: RowProps['row'] = {
+ rowData: {
+ displayName: '',
+ mail: '',
+ givenName: '',
+ userId: '',
+ customAttributes: [],
+ },
+ }
+ render(, { wrapper: Wrapper })
+ expect(screen.getAllByText('—').length).toBeGreaterThan(0)
+ })
+})
diff --git a/admin-ui/plugins/user-management/__tests__/components/UserDeviceDetailViewPage.test.tsx b/admin-ui/plugins/user-management/__tests__/components/UserDeviceDetailViewPage.test.tsx
new file mode 100644
index 000000000..7a9451577
--- /dev/null
+++ b/admin-ui/plugins/user-management/__tests__/components/UserDeviceDetailViewPage.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen } from '@testing-library/react'
+import {
+ createUserManagementTestStore,
+ createUserManagementQueryClient,
+ createUserManagementTestWrapper,
+} from 'Plugins/user-management/__tests__/helpers/userManagementTestUtils'
+import UserDeviceDetailViewPage from 'Plugins/user-management/components/UserDeviceDetailViewPage'
+import type { UserDeviceDetailViewPageProps } from 'Plugins/user-management/types'
+
+const store = createUserManagementTestStore()
+const queryClient = createUserManagementQueryClient()
+const Wrapper = createUserManagementTestWrapper(store, queryClient)
+
+const rowWithDevice: UserDeviceDetailViewPageProps['row'] = {
+ rowData: {
+ registrationData: {
+ domain: 'example.com',
+ type: 'platform',
+ status: 'registered',
+ createdBy: 'admin',
+ },
+ deviceData: {
+ name: 'My Phone',
+ os_name: 'iOS',
+ os_version: '17.1',
+ platform: 'mobile',
+ },
+ },
+}
+
+describe('UserDeviceDetailViewPage', () => {
+ afterEach(() => {
+ queryClient.clear()
+ })
+
+ it('renders without crashing', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('Domain:')).toBeInTheDocument()
+ })
+
+ it('renders registration field labels', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('Domain:')).toBeInTheDocument()
+ expect(screen.getByText('Type:')).toBeInTheDocument()
+ expect(screen.getByText('Status:')).toBeInTheDocument()
+ expect(screen.getByText('Created By:')).toBeInTheDocument()
+ })
+
+ it('populates registration values', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('example.com')).toBeInTheDocument()
+ expect(screen.getByText('platform')).toBeInTheDocument()
+ expect(screen.getByText('registered')).toBeInTheDocument()
+ expect(screen.getByText('admin')).toBeInTheDocument()
+ })
+
+ it('renders the device information section when deviceData is present', () => {
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('Device Information')).toBeInTheDocument()
+ expect(screen.getByText('My Phone')).toBeInTheDocument()
+ expect(screen.getByText('iOS')).toBeInTheDocument()
+ expect(screen.getByText('17.1')).toBeInTheDocument()
+ expect(screen.getByText('mobile')).toBeInTheDocument()
+ })
+
+ it('falls back to rpId when domain is absent', () => {
+ const row: UserDeviceDetailViewPageProps['row'] = {
+ rowData: {
+ registrationData: { rpId: 'rp.example.org', type: 'cross-platform' },
+ },
+ }
+ render(, { wrapper: Wrapper })
+ expect(screen.getByText('rp.example.org')).toBeInTheDocument()
+ })
+
+ it('hides the device information section when deviceData is absent', () => {
+ const row: UserDeviceDetailViewPageProps['row'] = {
+ rowData: {
+ registrationData: { domain: 'example.com' },
+ },
+ }
+ render(, { wrapper: Wrapper })
+ expect(screen.queryByText('Device Information')).not.toBeInTheDocument()
+ })
+})
diff --git a/admin-ui/plugins/user-management/__tests__/components/UserForm.test.tsx b/admin-ui/plugins/user-management/__tests__/components/UserForm.test.tsx
new file mode 100644
index 000000000..0bd727ff3
--- /dev/null
+++ b/admin-ui/plugins/user-management/__tests__/components/UserForm.test.tsx
@@ -0,0 +1,217 @@
+import React, { act } from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import {
+ createUserManagementTestStore,
+ createUserManagementQueryClient,
+ createUserManagementTestWrapper,
+} from 'Plugins/user-management/__tests__/helpers/userManagementTestUtils'
+import UserForm from 'Plugins/user-management/components/UserForm'
+import type { CustomUser, CustomAttribute, PersonAttribute } from 'Plugins/user-management/types'
+
+const store = createUserManagementTestStore()
+const queryClient = createUserManagementQueryClient()
+const Wrapper = createUserManagementTestWrapper(store, queryClient)
+
+const personAttributes: PersonAttribute[] = []
+
+// The form maps string-valued custom attributes (e.g. `sn`) into form fields via
+// initializeCustomAttributes, which reads `values[0]` as a string at runtime. The generated
+// CustomUser type models customAttributes with object-only values, so the string `sn` value is
+// supplied through the project's looser CustomAttribute type and the user is assembled from it.
+const snAttribute: CustomAttribute = { name: 'sn', values: ['Doe'] }
+
+const mockUser: CustomUser = {
+ inum: 'test-inum-123',
+ userId: 'jdoe',
+ displayName: 'John Doe',
+ givenName: 'John',
+ mail: 'jdoe@example.com',
+ status: 'active',
+ customAttributes: [snAttribute] as CustomUser['customAttributes'],
+}
+
+const onSubmitData = jest.fn()
+
+const renderForm = async (ui: React.ReactElement) => {
+ let result: ReturnType
+ await act(async () => {
+ result = render(ui, { wrapper: Wrapper })
+ })
+ await screen.findByTestId('userId')
+ return result!
+}
+
+describe('UserForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ queryClient.clear()
+ })
+
+ describe('edit mode (with userDetails)', () => {
+ it('renders without crashing and shows core fields', async () => {
+ await renderForm(
+ ,
+ )
+ expect(screen.getByText(/First Name/i)).toBeInTheDocument()
+ expect(screen.getByText(/Email/i)).toBeInTheDocument()
+ })
+
+ it('populates first name from userDetails', async () => {
+ await renderForm(
+ ,
+ )
+ expect(screen.getByTestId('givenName')).toHaveValue('John')
+ })
+
+ it('populates user name, display name, email and last name', async () => {
+ await renderForm(
+ ,
+ )
+ expect(screen.getByTestId('userId')).toHaveValue('jdoe')
+ expect(screen.getByTestId('displayName')).toHaveValue('John Doe')
+ expect(screen.getByTestId('mail')).toHaveValue('jdoe@example.com')
+ expect(screen.getByTestId('sn')).toHaveValue('Doe')
+ })
+
+ it('renders the inum field as disabled', async () => {
+ await renderForm(
+ ,
+ )
+ const inum = screen.getByTestId('inum')
+ expect(inum).toHaveValue('test-inum-123')
+ expect(inum).toBeDisabled()
+ })
+
+ it('renders the change password button in edit mode', async () => {
+ await renderForm(
+ ,
+ )
+ expect(screen.getByText(/Change Password/i)).toBeInTheDocument()
+ })
+
+ it('does not render password fields in edit mode', async () => {
+ await renderForm(
+ ,
+ )
+ expect(screen.queryByTestId('userPassword')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('userConfirmPassword')).not.toBeInTheDocument()
+ })
+
+ it('renders footer Back, Cancel and Apply buttons', async () => {
+ await renderForm(
+ ,
+ )
+ expect(screen.getByText(/Back/i)).toBeInTheDocument()
+ expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/Apply/i)).toBeInTheDocument()
+ })
+
+ it('disables Apply and Cancel when the form is pristine', async () => {
+ await renderForm(
+ ,
+ )
+ expect(screen.getByText(/Apply/i).closest('button')).toBeDisabled()
+ expect(screen.getByText(/Cancel/i).closest('button')).toBeDisabled()
+ })
+ })
+
+ describe('add mode (no userDetails)', () => {
+ it('renders empty core fields', async () => {
+ await renderForm()
+ expect(screen.getByTestId('givenName')).toHaveValue('')
+ expect(screen.getByTestId('userId')).toHaveValue('')
+ expect(screen.getByTestId('mail')).toHaveValue('')
+ })
+
+ it('renders password and confirm password fields', async () => {
+ await renderForm()
+ expect(screen.getByTestId('userPassword')).toBeInTheDocument()
+ expect(screen.getByTestId('userConfirmPassword')).toBeInTheDocument()
+ })
+
+ it('does not render the inum field', async () => {
+ await renderForm()
+ expect(screen.queryByTestId('inum')).not.toBeInTheDocument()
+ })
+
+ it('does not render the change password button', async () => {
+ await renderForm()
+ expect(screen.queryByText(/Change Password/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('isSubmitting behavior', () => {
+ it('disables the Apply button while submitting', async () => {
+ await renderForm(
+ ,
+ )
+ expect(screen.getByText(/Apply/i).closest('button')).toBeDisabled()
+ })
+ })
+
+ describe('form interactions', () => {
+ it('allows typing in the first name field', async () => {
+ await renderForm()
+ const input = screen.getByTestId('givenName')
+ await act(async () => {
+ fireEvent.change(input, { target: { value: 'Jane' } })
+ })
+ expect(input).toHaveValue('Jane')
+ })
+
+ it('enables Apply once a field is changed in edit mode', async () => {
+ await renderForm(
+ ,
+ )
+ await act(async () => {
+ fireEvent.change(screen.getByTestId('displayName'), {
+ target: { value: 'John Updated' },
+ })
+ })
+ await waitFor(() => {
+ expect(screen.getByText(/Apply/i).closest('button')).not.toBeDisabled()
+ })
+ })
+ })
+})