From 7b4b5b2108b4b83ab0e1b050a762194b996e5cfa Mon Sep 17 00:00:00 2001 From: faisalsiddique4400 Date: Tue, 23 Jun 2026 20:29:38 +0500 Subject: [PATCH] feat(admin-ui): add test files for pages across the system that currently do not have them (#2887) Signed-off-by: faisalsiddique4400 --- .../Webhook/WebhookAddPage.test.tsx | 109 +++++++ .../Webhook/WebhookEditPage.test.tsx | 109 +++++++ .../components/Webhook/WebhookForm.test.tsx | 261 ++++++++++++++++ .../Acrs/__tests__/AcrsForm.test.tsx | 130 ++++++++ .../ConfigApiPropertiesPage.test.tsx | 130 ++++++++ .../__tests__/components/JsonViewer.test.tsx | 47 +++ .../components/JsonViewerDialog.test.tsx | 129 ++++++++ .../Ssa/__tests__/components/SsaForm.test.tsx | 103 +++++++ .../components/Metrics/useMetricsApi.test.tsx | 166 ++++++++++ .../components/Metrics/utils.test.ts | 272 ++++++++++++++++ .../fido/__tests__/helper/validations.test.ts | 139 +++++++++ .../fido/__tests__/hooks/useFidoApi.test.tsx | 153 +++++++++ .../components/SamlConfigurationForm.test.tsx | 169 ++++++++++ .../__tests__/components/SamlPage.test.tsx | 84 +++++ .../WebsiteSsoIdentityProviderForm.test.tsx | 181 +++++++++++ .../WebsiteSsoServiceProviderForm.test.tsx | 207 +++++++++++++ .../components/ScimConfiguration.test.tsx | 198 ++++++++++++ .../components/ScimFieldRenderer.test.tsx | 291 ++++++++++++++++++ .../components/CustomScriptListPage.test.tsx | 191 ++++++++++++ .../components/CacheInMemory.test.tsx | 57 ++++ .../components/CacheMemcached.test.tsx | 67 ++++ .../__tests__/components/CacheNative.test.tsx | 71 +++++ .../__tests__/components/CacheRedis.test.tsx | 81 +++++ .../components/hooks/useCacheAudit.test.ts | 92 ++++++ .../components/UserClaimsViewPage.test.tsx | 172 +++++++++++ .../components/UserDetailViewPage.test.tsx | 73 +++++ .../UserDeviceDetailViewPage.test.tsx | 85 +++++ .../__tests__/components/UserForm.test.tsx | 217 +++++++++++++ 28 files changed, 3984 insertions(+) create mode 100644 admin-ui/plugins/admin/__tests__/components/Webhook/WebhookAddPage.test.tsx create mode 100644 admin-ui/plugins/admin/__tests__/components/Webhook/WebhookEditPage.test.tsx create mode 100644 admin-ui/plugins/admin/__tests__/components/Webhook/WebhookForm.test.tsx create mode 100644 admin-ui/plugins/auth-server/components/Authentication/Acrs/__tests__/AcrsForm.test.tsx create mode 100644 admin-ui/plugins/auth-server/components/ConfigApiProperties/__tests__/components/ConfigApiPropertiesPage.test.tsx create mode 100644 admin-ui/plugins/auth-server/components/JsonViewer/__tests__/components/JsonViewer.test.tsx create mode 100644 admin-ui/plugins/auth-server/components/JsonViewer/__tests__/components/JsonViewerDialog.test.tsx create mode 100644 admin-ui/plugins/auth-server/components/Ssa/__tests__/components/SsaForm.test.tsx create mode 100644 admin-ui/plugins/fido/__tests__/components/Metrics/useMetricsApi.test.tsx create mode 100644 admin-ui/plugins/fido/__tests__/components/Metrics/utils.test.ts create mode 100644 admin-ui/plugins/fido/__tests__/helper/validations.test.ts create mode 100644 admin-ui/plugins/fido/__tests__/hooks/useFidoApi.test.tsx create mode 100644 admin-ui/plugins/saml/__tests__/components/SamlConfigurationForm.test.tsx create mode 100644 admin-ui/plugins/saml/__tests__/components/SamlPage.test.tsx create mode 100644 admin-ui/plugins/saml/__tests__/components/WebsiteSsoIdentityProviderForm.test.tsx create mode 100644 admin-ui/plugins/saml/__tests__/components/WebsiteSsoServiceProviderForm.test.tsx create mode 100644 admin-ui/plugins/scim/__tests__/components/ScimConfiguration.test.tsx create mode 100644 admin-ui/plugins/scim/__tests__/components/ScimFieldRenderer.test.tsx create mode 100644 admin-ui/plugins/scripts/__tests__/components/CustomScriptListPage.test.tsx create mode 100644 admin-ui/plugins/services/__tests__/components/CacheInMemory.test.tsx create mode 100644 admin-ui/plugins/services/__tests__/components/CacheMemcached.test.tsx create mode 100644 admin-ui/plugins/services/__tests__/components/CacheNative.test.tsx create mode 100644 admin-ui/plugins/services/__tests__/components/CacheRedis.test.tsx create mode 100644 admin-ui/plugins/services/__tests__/components/hooks/useCacheAudit.test.ts create mode 100644 admin-ui/plugins/user-claims/__tests__/components/UserClaimsViewPage.test.tsx create mode 100644 admin-ui/plugins/user-management/__tests__/components/UserDetailViewPage.test.tsx create mode 100644 admin-ui/plugins/user-management/__tests__/components/UserDeviceDetailViewPage.test.tsx create mode 100644 admin-ui/plugins/user-management/__tests__/components/UserForm.test.tsx 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() + }) + }) + }) +})