From 2acf4346aa91d953d183944ddfe8617ffd18319c Mon Sep 17 00:00:00 2001 From: faisalsiddique4400 Date: Thu, 2 Jul 2026 16:57:39 +0500 Subject: [PATCH 1/3] feat(admin-ui): add test cases for all remaining uncovered files (#2915) Signed-off-by: faisalsiddique4400 --- .../__tests__/AccordionParts.test.tsx | 68 ++++++ .../AuthenticatedRouteSelector.test.tsx | 110 ++++++++++ .../PermissionsPolicyInitializer.test.tsx | 111 ++++++++++ .../__tests__/DefaultSidebar.test.tsx | 85 ++++++++ .../app/redux/api/__tests__/axios.test.ts | 76 +++++++ .../redux/api/__tests__/backend-api.test.ts | 160 ++++++++++++++ .../__tests__/ReducerRegistry.test.ts | 65 ++++++ .../GluuAdvancedSearch.test.tsx | 0 .../GluuAppSiderbar.test.tsx | 0 .../GluuAutocomplete.test.tsx | 2 +- .../GluuErrorScreen.test.tsx | 0 .../GluuFormDetailRow.test.tsx | 2 +- .../GluuInlineInput.test.tsx | 2 +- .../GluuInputRow.test.tsx | 2 +- .../GluuInumInput.test.tsx | 2 +- .../{Tests => __tests__}/GluuLabel.test.tsx | 0 .../{Tests => __tests__}/GluuNavBar.test.tsx | 2 +- .../GluuSelectRow.test.tsx | 2 +- .../{Tests => __tests__}/GluuToggle.test.tsx | 0 .../GluuToggleRow.test.tsx | 2 +- .../GluuToogleRow.test.tsx | 0 .../{Tests => __tests__}/GluuTooltip.test.tsx | 2 +- .../GluuViewWrapper.test.tsx | 0 .../LanguageMenu.test.tsx | 2 +- .../components}/UserIcon.test.tsx | 0 .../hooks}/useNavbarTheme.test.ts | 2 +- .../hooks}/usePageTitle.test.ts | 2 +- .../Chart/__tests__/DashboardChart.test.tsx | 127 +++++++++++ .../components/__tests__/SummaryCard.test.tsx | 31 +++ .../LicenseDetailsPage.test.tsx | 8 +- .../routes/Pages/__tests__/ByeBye.test.tsx | 150 +++++++++++++ .../Pages/__tests__/ProtectRoutes.test.tsx | 43 ++++ .../app/utils/__tests__/RouteLoader.test.tsx | 58 +++++ admin-ui/app/utils/__tests__/logger.test.ts | 72 +++++++ .../triggerWebhookForFeature.test.ts | 57 +++++ .../__tests__/PluginListenersResolver.test.ts | 34 +++ .../__tests__/PluginMenuResolver.test.ts | 85 ++++++++ .../__tests__/PluginReducersResolver.test.ts | 50 +++++ .../Asset/JansAssetAddPage.test.tsx | 0 .../Asset/JansAssetEditPage.test.tsx | 0 .../Asset/JansAssetListPage.test.tsx | 0 .../Asset/assetCedarTestConstants.ts | 0 .../{ => Assets}/Asset/assetTestUtils.tsx | 0 .../__tests__/ServiceStatusCard.test.tsx | 52 +++++ .../__tests__/MauTrendChart.test.tsx | 48 +++++ .../__tests__/TokenDistributionChart.test.tsx | 49 +++++ .../__tests__/TokenTrendChart.test.tsx | 48 +++++ .../__tests__/ShortcodePopover.test.tsx | 54 +++++ .../utils/{ => __tests__}/helpers.test.ts | 2 +- .../__tests__/DefaultAcrInput.test.tsx | 57 +++++ .../AvailableCustomAttributesPanel.test.tsx | 71 +++++++ .../__tests__/jsonPropertiesService.test.ts | 133 ++++++++++++ .../PersonAuthenticationFields.test.tsx | 146 +++++++++++++ .../components/CacheInMemory.test.tsx | 4 +- .../components/CacheMemcached.test.tsx | 4 +- .../__tests__/components/CacheNative.test.tsx | 4 +- .../__tests__/components/CachePage.test.tsx | 2 +- .../__tests__/components/CacheRedis.test.tsx | 4 +- .../components/PersistenceDetail.test.tsx | 2 +- .../components/hooks/useCacheAudit.test.ts | 2 +- .../__tests__/helper/validations.test.ts | 2 +- .../CacheInMemory.tsx | 0 .../CacheMemcached.tsx | 0 .../CacheNative.tsx | 0 .../{Components => components}/CachePage.tsx | 0 .../{Components => components}/CacheRedis.tsx | 0 .../PersistenceDetail.tsx | 0 .../{Components => components}/hooks/index.ts | 0 .../hooks/useCacheAudit.ts | 0 .../styles/CachePage.style.ts | 0 .../styles/PersistenceDetail.style.ts | 0 .../{Components => components}/types.ts | 0 admin-ui/plugins/services/helper/constants.ts | 2 +- admin-ui/plugins/services/helper/utils.ts | 2 +- admin-ui/plugins/services/plugin-metadata.ts | 4 +- .../__tests__/AvailableClaimsPanel.test.tsx | 79 +++++++ .../__tests__/UserClaimEntry.test.tsx | 201 ++++++++++++++++++ 77 files changed, 2353 insertions(+), 33 deletions(-) create mode 100644 admin-ui/app/components/Accordion/__tests__/AccordionParts.test.tsx create mode 100644 admin-ui/app/components/App/__tests__/AuthenticatedRouteSelector.test.tsx create mode 100644 admin-ui/app/components/App/__tests__/PermissionsPolicyInitializer.test.tsx create mode 100644 admin-ui/app/layout/components/__tests__/DefaultSidebar.test.tsx create mode 100644 admin-ui/app/redux/api/__tests__/axios.test.ts create mode 100644 admin-ui/app/redux/api/__tests__/backend-api.test.ts create mode 100644 admin-ui/app/redux/reducers/__tests__/ReducerRegistry.test.ts rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuAdvancedSearch.test.tsx (100%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuAppSiderbar.test.tsx (100%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuAutocomplete.test.tsx (97%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuErrorScreen.test.tsx (100%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuFormDetailRow.test.tsx (85%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuInlineInput.test.tsx (95%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuInputRow.test.tsx (86%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuInumInput.test.tsx (87%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuLabel.test.tsx (100%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuNavBar.test.tsx (96%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuSelectRow.test.tsx (96%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuToggle.test.tsx (100%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuToggleRow.test.tsx (95%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuToogleRow.test.tsx (100%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuTooltip.test.tsx (94%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/GluuViewWrapper.test.tsx (100%) rename admin-ui/app/routes/Apps/Gluu/{Tests => __tests__}/LanguageMenu.test.tsx (88%) rename admin-ui/app/routes/Apps/Gluu/{components/__tests__ => __tests__/components}/UserIcon.test.tsx (100%) rename admin-ui/app/routes/Apps/Gluu/{hooks/__tests__ => __tests__/hooks}/useNavbarTheme.test.ts (97%) rename admin-ui/app/routes/Apps/Gluu/{hooks/__tests__ => __tests__/hooks}/usePageTitle.test.ts (95%) create mode 100644 admin-ui/app/routes/Dashboards/Chart/__tests__/DashboardChart.test.tsx create mode 100644 admin-ui/app/routes/Dashboards/components/__tests__/SummaryCard.test.tsx rename admin-ui/app/routes/License/{ => __tests__}/LicenseDetailsPage.test.tsx (89%) create mode 100644 admin-ui/app/routes/Pages/__tests__/ByeBye.test.tsx create mode 100644 admin-ui/app/routes/Pages/__tests__/ProtectRoutes.test.tsx create mode 100644 admin-ui/app/utils/__tests__/RouteLoader.test.tsx create mode 100644 admin-ui/app/utils/__tests__/logger.test.ts create mode 100644 admin-ui/app/utils/__tests__/triggerWebhookForFeature.test.ts create mode 100644 admin-ui/plugins/__tests__/PluginListenersResolver.test.ts create mode 100644 admin-ui/plugins/__tests__/PluginMenuResolver.test.ts create mode 100644 admin-ui/plugins/__tests__/PluginReducersResolver.test.ts rename admin-ui/plugins/admin/__tests__/components/{ => Assets}/Asset/JansAssetAddPage.test.tsx (100%) rename admin-ui/plugins/admin/__tests__/components/{ => Assets}/Asset/JansAssetEditPage.test.tsx (100%) rename admin-ui/plugins/admin/__tests__/components/{ => Assets}/Asset/JansAssetListPage.test.tsx (100%) rename admin-ui/plugins/admin/__tests__/components/{ => Assets}/Asset/assetCedarTestConstants.ts (100%) rename admin-ui/plugins/admin/__tests__/components/{ => Assets}/Asset/assetTestUtils.tsx (100%) create mode 100644 admin-ui/plugins/admin/components/Health/components/__tests__/ServiceStatusCard.test.tsx create mode 100644 admin-ui/plugins/admin/components/MAU/components/__tests__/MauTrendChart.test.tsx create mode 100644 admin-ui/plugins/admin/components/MAU/components/__tests__/TokenDistributionChart.test.tsx create mode 100644 admin-ui/plugins/admin/components/MAU/components/__tests__/TokenTrendChart.test.tsx create mode 100644 admin-ui/plugins/admin/components/Webhook/__tests__/ShortcodePopover.test.tsx rename admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/{ => __tests__}/helpers.test.ts (91%) create mode 100644 admin-ui/plugins/auth-server/components/AuthServerProperties/components/__tests__/DefaultAcrInput.test.tsx create mode 100644 admin-ui/plugins/auth-server/components/Ssa/components/__tests__/AvailableCustomAttributesPanel.test.tsx create mode 100644 admin-ui/plugins/auth-server/services/__tests__/jsonPropertiesService.test.ts create mode 100644 admin-ui/plugins/scripts/__tests__/components/PersonAuthenticationFields.test.tsx rename admin-ui/plugins/services/{Components => components}/CacheInMemory.tsx (100%) rename admin-ui/plugins/services/{Components => components}/CacheMemcached.tsx (100%) rename admin-ui/plugins/services/{Components => components}/CacheNative.tsx (100%) rename admin-ui/plugins/services/{Components => components}/CachePage.tsx (100%) rename admin-ui/plugins/services/{Components => components}/CacheRedis.tsx (100%) rename admin-ui/plugins/services/{Components => components}/PersistenceDetail.tsx (100%) rename admin-ui/plugins/services/{Components => components}/hooks/index.ts (100%) rename admin-ui/plugins/services/{Components => components}/hooks/useCacheAudit.ts (100%) rename admin-ui/plugins/services/{Components => components}/styles/CachePage.style.ts (100%) rename admin-ui/plugins/services/{Components => components}/styles/PersistenceDetail.style.ts (100%) rename admin-ui/plugins/services/{Components => components}/types.ts (100%) create mode 100644 admin-ui/plugins/user-management/components/__tests__/AvailableClaimsPanel.test.tsx create mode 100644 admin-ui/plugins/user-management/components/__tests__/UserClaimEntry.test.tsx diff --git a/admin-ui/app/components/Accordion/__tests__/AccordionParts.test.tsx b/admin-ui/app/components/Accordion/__tests__/AccordionParts.test.tsx new file mode 100644 index 0000000000..8f0a67b12c --- /dev/null +++ b/admin-ui/app/components/Accordion/__tests__/AccordionParts.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { AccordionContext } from '../context' +import { AccordionIndicator } from '../AccordionIndicator' +import { AccordionHeader } from '../AccordionHeader' +import { AccordionBody } from '../AccordionBody' +import type { AccordionContextType } from '../types' + +const withContext = (ctx: Partial, ui: React.ReactNode) => + render( + {}, ...ctx }}> + {ui} + , + ) + +describe('AccordionIndicator', () => { + it('renders the closed indicator (add icon) when collapsed', () => { + const { container } = withContext( + { isOpen: false }, + open} + closed={closed} + />, + ) + expect(screen.getByTestId('closed-icon')).toBeInTheDocument() + expect(screen.queryByTestId('open-icon')).not.toBeInTheDocument() + expect(container).toBeInTheDocument() + }) + + it('renders the open indicator (remove icon) when expanded', () => { + withContext( + { isOpen: true }, + open} + closed={closed} + />, + ) + expect(screen.getByTestId('open-icon')).toBeInTheDocument() + expect(screen.queryByTestId('closed-icon')).not.toBeInTheDocument() + }) + + it('merges the passed className with the indicator element className', () => { + withContext( + { isOpen: true }, + x} />, + ) + const el = screen.getByText('x') + expect(el).toHaveClass('extra') + expect(el).toHaveClass('own') + }) +}) + +describe('AccordionBody', () => { + it('renders children inside a card-body wrapper when open', () => { + withContext({ isOpen: true }, body content) + const content = screen.getByText('body content') + expect(content).toBeInTheDocument() + expect(content).toHaveClass('card-body', 'mine', 'pt-0') + }) +}) + +describe('AccordionHeader', () => { + it('invokes onToggle from context when the header is clicked', () => { + const onToggle = jest.fn() + withContext({ onToggle }, header) + fireEvent.click(screen.getByText('header')) + expect(onToggle).toHaveBeenCalledTimes(1) + }) +}) diff --git a/admin-ui/app/components/App/__tests__/AuthenticatedRouteSelector.test.tsx b/admin-ui/app/components/App/__tests__/AuthenticatedRouteSelector.test.tsx new file mode 100644 index 0000000000..28c3600696 --- /dev/null +++ b/admin-ui/app/components/App/__tests__/AuthenticatedRouteSelector.test.tsx @@ -0,0 +1,110 @@ +import React, { type PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' +import AuthenticatedRouteSelector from '../AuthenticatedRouteSelector' + +const mockLocation = { pathname: '/' } +jest.mock('react-router-dom', () => ({ useLocation: () => mockLocation })) + +jest.mock('@/helpers/navigation', () => ({ + ROUTES: { + ROOT: '/', + LOGOUT: '/admin/logout', + HOME_DASHBOARD: '/home/dashboard', + PROFILE: '/profile', + }, +})) + +// Children are covered by their own suites; stub to markers and record mockPreloads. +// mock-prefixed so the hoisted factory may reference it. +const mockPreloads = { + DashboardPage: jest.fn(), + ProfilePage: jest.fn(), + GluuToast: jest.fn(), + DefaultSidebar: jest.fn(), + GluuNavBar: jest.fn(), + GluuWebhookExecutionDialog: jest.fn(), +} +jest.mock('Utils/RouteLoader', () => { + const marker = (testid?: string) => () => (testid ?
: null) + const lazy = (preload: () => void, testid?: string) => Object.assign(marker(testid), { preload }) + return { + LazyRoutes: { + DashboardPage: lazy(() => mockPreloads.DashboardPage()), + ProfilePage: lazy(() => mockPreloads.ProfilePage()), + GluuToast: lazy(() => mockPreloads.GluuToast(), 'toast'), + DefaultSidebar: lazy(() => mockPreloads.DefaultSidebar()), + GluuNavBar: lazy(() => mockPreloads.GluuNavBar()), + GluuWebhookExecutionDialog: lazy( + () => mockPreloads.GluuWebhookExecutionDialog(), + 'webhook-dialog', + ), + ByeBye: lazy(() => {}, 'byebye'), + }, + } +}) + +jest.mock('../../../layout/default', () => ({ + __esModule: true, + default: ({ children }: PropsWithChildren) =>
{children}
, +})) +jest.mock('../../../routes/index', () => ({ + RoutedContent: () =>
, +})) +jest.mock('Utils/AppAuthProvider', () => ({ + __esModule: true, + default: ({ children }: PropsWithChildren) =>
{children}
, +})) +jest.mock('../PermissionsPolicyInitializer', () => ({ + __esModule: true, + default: () =>
, +})) + +beforeEach(() => { + Object.values(mockPreloads).forEach((p) => p.mockClear()) + mockLocation.pathname = '/' +}) + +describe('AuthenticatedRouteSelector', () => { + it('renders the ByeBye component on the logout route', () => { + mockLocation.pathname = '/admin/logout' + render() + expect(screen.getByTestId('byebye')).toBeInTheDocument() + expect(screen.queryByTestId('routed-content')).not.toBeInTheDocument() + }) + + it('renders the authenticated app shell on a normal route', () => { + render() + expect(screen.getByTestId('auth-provider')).toBeInTheDocument() + expect(screen.getByTestId('layout')).toBeInTheDocument() + expect(screen.getByTestId('routed-content')).toBeInTheDocument() + expect(screen.getByTestId('perms-init')).toBeInTheDocument() + }) + + it('always preloads the core chrome routes', () => { + render() + expect(mockPreloads.GluuToast).toHaveBeenCalled() + expect(mockPreloads.DefaultSidebar).toHaveBeenCalled() + expect(mockPreloads.GluuNavBar).toHaveBeenCalled() + expect(mockPreloads.GluuWebhookExecutionDialog).toHaveBeenCalled() + }) + + it('preloads the dashboard on the root route', () => { + mockLocation.pathname = '/' + render() + expect(mockPreloads.DashboardPage).toHaveBeenCalled() + expect(mockPreloads.ProfilePage).not.toHaveBeenCalled() + }) + + it('preloads the dashboard on the home-dashboard route', () => { + mockLocation.pathname = '/home/dashboard' + render() + expect(mockPreloads.DashboardPage).toHaveBeenCalled() + }) + + it('preloads the profile page on the profile route', () => { + mockLocation.pathname = '/profile' + render() + expect(mockPreloads.ProfilePage).toHaveBeenCalled() + expect(mockPreloads.DashboardPage).not.toHaveBeenCalled() + }) +}) diff --git a/admin-ui/app/components/App/__tests__/PermissionsPolicyInitializer.test.tsx b/admin-ui/app/components/App/__tests__/PermissionsPolicyInitializer.test.tsx new file mode 100644 index 0000000000..55ab6a821d --- /dev/null +++ b/admin-ui/app/components/App/__tests__/PermissionsPolicyInitializer.test.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import PermissionsPolicyInitializer from '../PermissionsPolicyInitializer' +import { CEDARLING_LOG_TYPE } from '@/cedarling/constants' + +type CedarInitState = { + authReducer: { hasSession: boolean; config: { cedarlingLogType: string } } + cedarPermissions: { initialized: boolean; isInitializing: boolean; policyStoreBytes: string } +} + +// jest hoists mock factories above imports, so any variable a factory closes +// over must be prefixed with `mock` to satisfy the babel-jest guard. +const mockDispatch = jest.fn() +const mockState: CedarInitState = { + authReducer: { hasSession: true, config: { cedarlingLogType: 'std_out' } }, + cedarPermissions: { initialized: false, isInitializing: false, policyStoreBytes: 'dmFsaWQ=' }, +} +jest.mock('@/redux/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: (fn: (s: CedarInitState) => T) => fn(mockState), +})) + +const mockInitialize = jest.fn() +jest.mock('@/cedarling/client', () => ({ + cedarlingClient: { + initialize: (config: object, bytes: Uint8Array) => mockInitialize(config, bytes), + }, +})) +jest.mock('@/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +// Action creators return identifiable plain objects so dispatch calls can be asserted. +jest.mock('../../../redux/features/cedarPermissionsSlice', () => ({ + setCedarlingInitializing: (v: boolean) => ({ type: 'initializing', payload: v }), + setCedarlingInitialized: (v: boolean) => ({ type: 'initialized', payload: v }), + setCedarFailedStatusAfterMaxTries: () => ({ type: 'failed' }), +})) + +const resetState = () => { + mockState.authReducer = { + hasSession: true, + config: { cedarlingLogType: CEDARLING_LOG_TYPE.STD_OUT }, + } + mockState.cedarPermissions = { + initialized: false, + isInitializing: false, + policyStoreBytes: 'dmFsaWQ=', + } +} + +describe('PermissionsPolicyInitializer', () => { + beforeEach(() => { + mockDispatch.mockClear() + mockInitialize.mockReset() + resetState() + }) + + it('renders nothing', () => { + mockInitialize.mockReturnValue(new Promise(() => {})) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('initializes cedarling when all gating conditions are met', async () => { + mockInitialize.mockResolvedValue(undefined) + render() + await waitFor(() => expect(mockInitialize).toHaveBeenCalledTimes(1)) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'initializing', payload: true }) + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith({ type: 'initialized', payload: true }), + ) + }) + + it('does not initialize when there is no session', () => { + mockState.authReducer.hasSession = false as never + render() + expect(mockInitialize).not.toHaveBeenCalled() + }) + + it('does not initialize when already initialized', () => { + mockState.cedarPermissions.initialized = true as never + render() + expect(mockInitialize).not.toHaveBeenCalled() + }) + + it('does not initialize when policy store bytes are blank', () => { + mockState.cedarPermissions.policyStoreBytes = ' ' as never + render() + expect(mockInitialize).not.toHaveBeenCalled() + }) + + it('aborts and clears the initializing flag when the policy store bytes fail to decode', () => { + mockState.cedarPermissions.policyStoreBytes = '@@not-base64@@' as never + render() + expect(mockInitialize).not.toHaveBeenCalled() + expect(mockDispatch).toHaveBeenCalledWith({ type: 'initializing', payload: false }) + }) + + it('schedules a retry (reset to uninitialized) on the first initialization failure', async () => { + jest.useFakeTimers() + mockInitialize.mockRejectedValue(new Error('boom')) + render() + await waitFor(() => expect(mockInitialize).toHaveBeenCalled()) + jest.advanceTimersByTime(1000) + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith({ type: 'initialized', payload: false }), + ) + jest.useRealTimers() + }) +}) diff --git a/admin-ui/app/layout/components/__tests__/DefaultSidebar.test.tsx b/admin-ui/app/layout/components/__tests__/DefaultSidebar.test.tsx new file mode 100644 index 0000000000..0ec42fe842 --- /dev/null +++ b/admin-ui/app/layout/components/__tests__/DefaultSidebar.test.tsx @@ -0,0 +1,85 @@ +import React, { type PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' +import { DefaultSidebar } from '../DefaultSidebar' + +type CedarState = { initialized: boolean; cedarFailedStatusAfterMaxTries: boolean } +type SidebarState = { cedarPermissions: CedarState } +type Selector = (state: SidebarState) => CedarState + +// Drive the cedarPermissions slice directly so the three loader branches can be +// exercised in isolation from the real store wiring. +const mockSelector = jest.fn() +jest.mock('@/redux/hooks', () => ({ + useAppSelector: (fn: Selector) => mockSelector(fn), +})) + +// Children have their own suites; stub to markers so the branch under test is +// unambiguous and no downstream provider is required. +jest.mock('Components', () => ({ + Sidebar: ({ children }: PropsWithChildren) =>
{children}
, +})) +jest.mock('Routes/components/LogoThemed/LogoThemed', () => ({ + LogoThemed: () =>
, +})) +jest.mock('@/routes/Apps/Gluu/GluuText', () => ({ + __esModule: true, + default: ({ children }: PropsWithChildren) =>
{children}
, +})) +jest.mock('Routes/Apps/Gluu/GluuAppSidebar', () => ({ + __esModule: true, + default: () =>
, +})) +jest.mock('@/components/GluuSpinner', () => ({ GluuSpinner: () =>
})) +jest.mock('react-router-dom', () => ({ + Link: ({ children }: PropsWithChildren) => {children}, +})) +jest.mock('../DefaultSidebar.style', () => ({ + useStyles: () => ({ + classes: { cedarMessageRoot: 'cedarMessageRoot', sidebarLoaderRoot: 'sidebarLoaderRoot' }, + }), +})) +jest.mock('@/helpers/navigation', () => ({ ROUTES: { ROOT: '/' } })) +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }) })) + +const setCedarState = (state: CedarState) => { + mockSelector.mockImplementation((fn: Selector) => fn({ cedarPermissions: state })) +} + +describe('DefaultSidebar', () => { + it('always renders the branded logo header', () => { + setCedarState({ initialized: true, cedarFailedStatusAfterMaxTries: false }) + render() + expect(screen.getByTestId('logo')).toBeInTheDocument() + }) + + it('renders the app sidebar once permissions are initialized', () => { + setCedarState({ initialized: true, cedarFailedStatusAfterMaxTries: false }) + render() + expect(screen.getByTestId('app-sidebar')).toBeInTheDocument() + expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() + }) + + it('shows the spinner while permissions are still loading', () => { + setCedarState({ initialized: false, cedarFailedStatusAfterMaxTries: false }) + render() + expect(screen.getByTestId('spinner')).toBeInTheDocument() + expect(screen.queryByTestId('app-sidebar')).not.toBeInTheDocument() + }) + + it('shows the missing-permission message when cedar fails before initializing', () => { + setCedarState({ initialized: false, cedarFailedStatusAfterMaxTries: true }) + render() + expect(screen.getByTestId('gluu-text')).toHaveTextContent( + 'messages.missing_required_permission', + ) + expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() + expect(screen.queryByTestId('app-sidebar')).not.toBeInTheDocument() + }) + + it('prefers the app sidebar over the failure message once initialized', () => { + setCedarState({ initialized: true, cedarFailedStatusAfterMaxTries: true }) + render() + expect(screen.getByTestId('app-sidebar')).toBeInTheDocument() + expect(screen.queryByTestId('gluu-text')).not.toBeInTheDocument() + }) +}) diff --git a/admin-ui/app/redux/api/__tests__/axios.test.ts b/admin-ui/app/redux/api/__tests__/axios.test.ts new file mode 100644 index 0000000000..d584474094 --- /dev/null +++ b/admin-ui/app/redux/api/__tests__/axios.test.ts @@ -0,0 +1,76 @@ +// axios.ts resolves its baseURL at module load, so each scenario sets the +// relevant globals, resets the module registry, then re-imports to observe the +// config passed to axios.create. +type AxiosConfig = { baseURL: string; timeout: number } +type AxiosInstance = { __instance: true; config: AxiosConfig } + +const mockCreate = jest.fn((config: AxiosConfig): AxiosInstance => ({ __instance: true, config })) +jest.mock('axios', () => ({ + __esModule: true, + default: { create: (c: AxiosConfig) => mockCreate(c) }, +})) + +const ORIGINAL_ENV = process.env.CONFIG_API_BASE_URL + +const loadAxios = async (): Promise => { + let instance!: AxiosInstance + await jest.isolateModulesAsync(async () => { + const mod: { default: object } = await import('../axios') + instance = mod.default as AxiosInstance + }) + return instance +} + +const lastConfig = (): AxiosConfig => mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] + +const setWindowUrl = (value?: string) => { + ;(window as { configApiBaseUrl?: string }).configApiBaseUrl = value +} + +beforeEach(() => { + mockCreate.mockClear() + delete (window as { configApiBaseUrl?: string }).configApiBaseUrl + delete process.env.CONFIG_API_BASE_URL +}) + +afterAll(() => { + if (ORIGINAL_ENV === undefined) delete process.env.CONFIG_API_BASE_URL + else process.env.CONFIG_API_BASE_URL = ORIGINAL_ENV +}) + +describe('axios instance', () => { + it('prefers a valid window.configApiBaseUrl', async () => { + setWindowUrl('https://runtime.example.com') + process.env.CONFIG_API_BASE_URL = 'https://env.example.com' + await loadAxios() + expect(lastConfig().baseURL).toBe('https://runtime.example.com') + }) + + it('ignores an unresolved python-placeholder window url and falls back to the env url', async () => { + setWindowUrl('%(config_api_base_url)s') + process.env.CONFIG_API_BASE_URL = 'https://env.example.com' + await loadAxios() + expect(lastConfig().baseURL).toBe('https://env.example.com') + }) + + it('falls back to the env url when no window url is present', async () => { + process.env.CONFIG_API_BASE_URL = 'https://env.example.com' + await loadAxios() + expect(lastConfig().baseURL).toBe('https://env.example.com') + }) + + it('falls back to localhost when neither window nor env url is set', async () => { + await loadAxios() + expect(lastConfig().baseURL).toBe('http://localhost:8080') + }) + + it('configures a 60s timeout on the created instance', async () => { + await loadAxios() + expect(lastConfig().timeout).toBe(60000) + }) + + it('exports the created axios instance', async () => { + const instance = await loadAxios() + expect(instance.__instance).toBe(true) + }) +}) diff --git a/admin-ui/app/redux/api/__tests__/backend-api.test.ts b/admin-ui/app/redux/api/__tests__/backend-api.test.ts new file mode 100644 index 0000000000..374d9aabda --- /dev/null +++ b/admin-ui/app/redux/api/__tests__/backend-api.test.ts @@ -0,0 +1,160 @@ +// Mock the shared axios instance so endpoint/URL/header/branch behavior can be +// asserted without a live server. The mock methods are created inside the +// factory (avoiding the hoisting TDZ) and pulled back out via the default export. +jest.mock('../axios', () => ({ + __esModule: true, + default: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() }, +})) +jest.mock('@/utils/logger', () => ({ logger: { error: jest.fn() } })) +jest.mock('@/utils/apiErrorMessage', () => ({ resolveApiErrorMessage: (e: Error) => e.message })) + +import mockAxiosDefault from '../axios' + +import { + fetchServerConfiguration, + putServerConfiguration, + fetchUserInformation, + postUserAction, + fetchApiTokenWithDefaultScopes, + fetchPolicyStore, + createAdminUiSession, + deleteAdminUiSession, + SESSION_ENDPOINT, +} from '../backend-api' + +type MockedAxios = { get: jest.Mock; put: jest.Mock; post: jest.Mock; delete: jest.Mock } +const ax = mockAxiosDefault as object as MockedAxios + +beforeEach(() => Object.values(ax).forEach((m) => m.mockReset())) + +describe('backend-api auth config', () => { + it('uses a bearer header when a token is supplied', async () => { + ax.get.mockResolvedValue({ data: { ok: true } }) + await fetchServerConfiguration('tok') + expect(ax.get).toHaveBeenCalledWith('/admin-ui/config', { + headers: { Authorization: 'Bearer tok' }, + }) + }) + + it('falls back to credentialed cookies when no token is supplied', async () => { + ax.get.mockResolvedValue({ data: {} }) + await fetchServerConfiguration() + expect(ax.get).toHaveBeenCalledWith('/admin-ui/config', { withCredentials: true }) + }) +}) + +describe('fetchServerConfiguration', () => { + it('returns response data on success', async () => { + ax.get.mockResolvedValue({ data: { issuer: 'x' } }) + await expect(fetchServerConfiguration()).resolves.toEqual({ issuer: 'x' }) + }) + + it('rethrows on failure', async () => { + ax.get.mockRejectedValue(new Error('down')) + await expect(fetchServerConfiguration()).rejects.toThrow('down') + }) +}) + +describe('putServerConfiguration', () => { + it('puts the props with the token-derived auth config', async () => { + ax.put.mockResolvedValue({ data: { saved: true } }) + await putServerConfiguration({ props: { a: 1 }, token: 'tok' } as never) + expect(ax.put).toHaveBeenCalledWith( + '/admin-ui/config', + { a: 1 }, + { + headers: { Authorization: 'Bearer tok' }, + }, + ) + }) +}) + +describe('fetchUserInformation', () => { + it('sends the token_type + access_token authorization header', async () => { + ax.get.mockResolvedValue({ data: 'userinfo' }) + const result = await fetchUserInformation({ + userInfoEndpoint: 'https://idp/userinfo', + token_type: 'Bearer', + access_token: 'abc', + } as never) + expect(ax.get).toHaveBeenCalledWith('https://idp/userinfo', { + headers: { Authorization: 'Bearer abc' }, + }) + expect(result).toBe('userinfo') + }) + + it('returns -1 instead of throwing on failure', async () => { + ax.get.mockRejectedValue(new Error('bad code')) + await expect( + fetchUserInformation({ + userInfoEndpoint: 'u', + token_type: 'Bearer', + access_token: 'x', + } as never), + ).resolves.toBe(-1) + }) +}) + +describe('postUserAction', () => { + it('strips headers from the action payload and returns status + data', async () => { + ax.post.mockResolvedValue({ status: 201, data: { id: 1 } }) + const result = await postUserAction({ action: 'PATCH', headers: { secret: 'x' } } as never) + const [, body] = ax.post.mock.calls[0] + expect(body.userAction).toEqual({ action: 'PATCH' }) + expect(body.userAction.headers).toBeUndefined() + expect(result).toEqual({ status: 201, data: { id: 1 } }) + }) + + it('rethrows on failure', async () => { + ax.post.mockRejectedValue(new Error('audit down')) + await expect(postUserAction({ action: 'X' } as never)).rejects.toThrow('audit down') + }) +}) + +describe('fetchApiTokenWithDefaultScopes', () => { + it('posts to the api-protection-token endpoint without credentials', async () => { + ax.post.mockResolvedValue({ data: { access_token: 't' } }) + await fetchApiTokenWithDefaultScopes() + expect(ax.post).toHaveBeenCalledWith( + '/app/admin-ui/oauth2/api-protection-token', + {}, + { withCredentials: false }, + ) + }) +}) + +describe('fetchPolicyStore', () => { + it('returns status + data on success', async () => { + ax.get.mockResolvedValue({ status: 200, data: { policies: [] } }) + await expect(fetchPolicyStore('tok')).resolves.toEqual({ status: 200, data: { policies: [] } }) + }) +}) + +describe('session endpoints', () => { + it('exposes the session endpoint constant', () => { + expect(SESSION_ENDPOINT).toBe('/app/admin-ui/oauth2/session') + }) + + it('creates a session with a bearer-authorized post', async () => { + ax.post.mockResolvedValue({ data: { created: true } }) + await createAdminUiSession('the-ujwt', 'prot-token') + expect(ax.post).toHaveBeenCalledWith( + '/app/admin-ui/oauth2/session', + { ujwt: 'the-ujwt' }, + { headers: { Authorization: 'Bearer prot-token' }, withCredentials: true }, + ) + }) + + it('deletes the session using the token-derived auth config', async () => { + ax.delete.mockResolvedValue({ data: {} }) + await deleteAdminUiSession() + expect(ax.delete).toHaveBeenCalledWith('/app/admin-ui/oauth2/session', { + withCredentials: true, + }) + }) + + it('rethrows when session deletion fails', async () => { + ax.delete.mockRejectedValue(new Error('nope')) + await expect(deleteAdminUiSession()).rejects.toThrow('nope') + }) +}) diff --git a/admin-ui/app/redux/reducers/__tests__/ReducerRegistry.test.ts b/admin-ui/app/redux/reducers/__tests__/ReducerRegistry.test.ts new file mode 100644 index 0000000000..38d56b0645 --- /dev/null +++ b/admin-ui/app/redux/reducers/__tests__/ReducerRegistry.test.ts @@ -0,0 +1,65 @@ +import type { Reducer, UnknownAction } from '@reduxjs/toolkit' + +type Registry = typeof import('../ReducerRegistry').default + +// The module exports a singleton; isolateModulesAsync gives each test a fresh +// registry so accumulated registrations don't leak between cases. +const freshRegistry = async (): Promise => { + let registry!: Registry + await jest.isolateModulesAsync(async () => { + registry = (await import('../ReducerRegistry')).default + }) + return registry +} + +const noopReducer: Reducer, UnknownAction> = (state = {}) => state + +describe('ReducerRegistry', () => { + it('starts empty', async () => { + expect((await freshRegistry()).getReducers()).toEqual({}) + }) + + it('registers a reducer and exposes it through getReducers', async () => { + const registry = await freshRegistry() + registry.register('auth', noopReducer) + expect(registry.getReducers()).toHaveProperty('auth', noopReducer) + }) + + it('returns a defensive copy from getReducers', async () => { + const registry = await freshRegistry() + registry.register('auth', noopReducer) + const snapshot = registry.getReducers() + ;(snapshot as Record).injected = noopReducer + expect(registry.getReducers()).not.toHaveProperty('injected') + }) + + it('reports registered reducers via hasReducer / getReducer', async () => { + const registry = await freshRegistry() + registry.register('license', noopReducer) + expect(registry.hasReducer('license')).toBe(true) + expect(registry.hasReducer('missing')).toBe(false) + expect(registry.getReducer('license' as never)).toBe(noopReducer) + }) + + it('notifies the change listener with the full reducer map on register', async () => { + const registry = await freshRegistry() + const listener = jest.fn() + registry.setChangeListener(listener) + registry.register('toast', noopReducer) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ toast: noopReducer })) + }) + + it('does not throw when registering before a listener is attached', async () => { + const registry = await freshRegistry() + expect(() => registry.register('preListener', noopReducer)).not.toThrow() + }) + + it('overwrites a reducer registered under an existing name', async () => { + const registry = await freshRegistry() + const replacement: Reducer, UnknownAction> = (state = { v: 1 }) => state + registry.register('dup', noopReducer) + registry.register('dup', replacement) + expect(registry.getReducer('dup' as never)).toBe(replacement) + }) +}) diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuAdvancedSearch.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuAdvancedSearch.test.tsx similarity index 100% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuAdvancedSearch.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuAdvancedSearch.test.tsx diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuAppSiderbar.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuAppSiderbar.test.tsx similarity index 100% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuAppSiderbar.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuAppSiderbar.test.tsx diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuAutocomplete.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuAutocomplete.test.tsx similarity index 97% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuAutocomplete.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuAutocomplete.test.tsx index 9529b8c8aa..58b330c574 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuAutocomplete.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuAutocomplete.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent, within } from '@testing-library/react' import GluuAutocomplete from '../GluuAutocomplete' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' import type { AutocompleteOption } from '../types/GluuAutocomplete.types' const NAME = 'servers' diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuErrorScreen.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuErrorScreen.test.tsx similarity index 100% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuErrorScreen.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuErrorScreen.test.tsx diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuFormDetailRow.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuFormDetailRow.test.tsx similarity index 85% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuFormDetailRow.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuFormDetailRow.test.tsx index daf0370377..46e3c5a8ff 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuFormDetailRow.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuFormDetailRow.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import GluuFormDetailRow from '../GluuFormDetailRow' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' const LABEL = 'fields.application_type' const VALUE = 'openid' diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuInlineInput.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuInlineInput.test.tsx similarity index 95% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuInlineInput.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuInlineInput.test.tsx index 7714978dca..6a646e2610 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuInlineInput.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuInlineInput.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent } from '@testing-library/react' import GluuInlineInput from '../GluuInlineInput' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' const LABEL = 'fields.application_type' const NAME = 'application_type' diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuInputRow.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuInputRow.test.tsx similarity index 86% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuInputRow.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuInputRow.test.tsx index 7494983103..61da895197 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuInputRow.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuInputRow.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import GluuInputRow from '../GluuInputRow' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' it('Should show the input with proper text', () => { const LABEL = 'fields.application_type' diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuInumInput.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuInumInput.test.tsx similarity index 87% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuInumInput.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuInumInput.test.tsx index ae70df0496..baf88972ac 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuInumInput.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuInumInput.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import GluuInumInput from '../GluuInumInput' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' const LABEL = 'fields.application_type' const NAME = 'application_type' diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuLabel.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuLabel.test.tsx similarity index 100% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuLabel.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuLabel.test.tsx diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuNavBar.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuNavBar.test.tsx similarity index 96% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuNavBar.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuNavBar.test.tsx index 008f2579a3..1f2c753570 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuNavBar.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuNavBar.test.tsx @@ -4,7 +4,7 @@ import { Provider } from 'react-redux' import { configureStore, combineReducers } from '@reduxjs/toolkit' import type { Store } from '@reduxjs/toolkit' import type { UserInfo } from 'Redux/features/types/authTypes' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' import GluuNavBar from '../GluuNavBar' const createTestStore = (userinfo: UserInfo | null): Store => diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuSelectRow.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuSelectRow.test.tsx similarity index 96% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuSelectRow.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuSelectRow.test.tsx index b182a26a30..5ebe7faa6c 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuSelectRow.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuSelectRow.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import GluuSelectRow from '../GluuSelectRow' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' const label = 'Select Option:' const name = 'selectName' diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuToggle.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuToggle.test.tsx similarity index 100% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuToggle.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuToggle.test.tsx diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuToggleRow.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuToggleRow.test.tsx similarity index 95% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuToggleRow.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuToggleRow.test.tsx index 0cd5567a73..7fee5a49ff 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuToggleRow.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuToggleRow.test.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import type { ChangeEvent } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import GluuToggleRow from '../GluuToggleRow' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' const LABEL = 'Enable Feature' const NAME = 'enabledFeature' diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuToogleRow.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuToogleRow.test.tsx similarity index 100% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuToogleRow.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuToogleRow.test.tsx diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuTooltip.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuTooltip.test.tsx similarity index 94% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuTooltip.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuTooltip.test.tsx index 7a17ff2872..2ca763f516 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/GluuTooltip.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuTooltip.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' import GluuTooltip from '../GluuTooltip' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' import userEvent from '@testing-library/user-event' it('Test gluutooltip', async () => { diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/GluuViewWrapper.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/GluuViewWrapper.test.tsx similarity index 100% rename from admin-ui/app/routes/Apps/Gluu/Tests/GluuViewWrapper.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/GluuViewWrapper.test.tsx diff --git a/admin-ui/app/routes/Apps/Gluu/Tests/LanguageMenu.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/LanguageMenu.test.tsx similarity index 88% rename from admin-ui/app/routes/Apps/Gluu/Tests/LanguageMenu.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/LanguageMenu.test.tsx index 7059229b6e..b67aed2c33 100644 --- a/admin-ui/app/routes/Apps/Gluu/Tests/LanguageMenu.test.tsx +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/LanguageMenu.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent } from '@testing-library/react' import { LanguageMenu } from '../LanguageMenu' -import AppTestWrapper from './Components/AppTestWrapper' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' it('All supported languages will be visible', () => { render( diff --git a/admin-ui/app/routes/Apps/Gluu/components/__tests__/UserIcon.test.tsx b/admin-ui/app/routes/Apps/Gluu/__tests__/components/UserIcon.test.tsx similarity index 100% rename from admin-ui/app/routes/Apps/Gluu/components/__tests__/UserIcon.test.tsx rename to admin-ui/app/routes/Apps/Gluu/__tests__/components/UserIcon.test.tsx diff --git a/admin-ui/app/routes/Apps/Gluu/hooks/__tests__/useNavbarTheme.test.ts b/admin-ui/app/routes/Apps/Gluu/__tests__/hooks/useNavbarTheme.test.ts similarity index 97% rename from admin-ui/app/routes/Apps/Gluu/hooks/__tests__/useNavbarTheme.test.ts rename to admin-ui/app/routes/Apps/Gluu/__tests__/hooks/useNavbarTheme.test.ts index aac6096271..f276cd2140 100644 --- a/admin-ui/app/routes/Apps/Gluu/hooks/__tests__/useNavbarTheme.test.ts +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/hooks/useNavbarTheme.test.ts @@ -4,7 +4,7 @@ import { ThemeContext } from '@/context/theme/themeContext' import type { ThemeContextType } from '@/context/theme/themeContext' import { themeConfig } from '@/context/theme/config' import { THEME_LIGHT, THEME_DARK, DEFAULT_THEME, type ThemeValue } from '@/context/theme/constants' -import { useNavbarTheme } from '../useNavbarTheme' +import { useNavbarTheme } from '../../hooks/useNavbarTheme' const makeWrapper = (theme: ThemeValue) => { const value: ThemeContextType = { diff --git a/admin-ui/app/routes/Apps/Gluu/hooks/__tests__/usePageTitle.test.ts b/admin-ui/app/routes/Apps/Gluu/__tests__/hooks/usePageTitle.test.ts similarity index 95% rename from admin-ui/app/routes/Apps/Gluu/hooks/__tests__/usePageTitle.test.ts rename to admin-ui/app/routes/Apps/Gluu/__tests__/hooks/usePageTitle.test.ts index 79cb35ac89..573ad920fb 100644 --- a/admin-ui/app/routes/Apps/Gluu/hooks/__tests__/usePageTitle.test.ts +++ b/admin-ui/app/routes/Apps/Gluu/__tests__/hooks/usePageTitle.test.ts @@ -1,6 +1,6 @@ import { renderHook, act } from '@testing-library/react' import setTitle from 'Utils/SetTitle' -import { usePageTitle } from '../usePageTitle' +import { usePageTitle } from '../../hooks/usePageTitle' describe('usePageTitle', () => { afterEach(() => { diff --git a/admin-ui/app/routes/Dashboards/Chart/__tests__/DashboardChart.test.tsx b/admin-ui/app/routes/Dashboards/Chart/__tests__/DashboardChart.test.tsx new file mode 100644 index 0000000000..8f42253f67 --- /dev/null +++ b/admin-ui/app/routes/Dashboards/Chart/__tests__/DashboardChart.test.tsx @@ -0,0 +1,127 @@ +import React, { type PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' +import DashboardChart from '../DashboardChart' +import { CHART_CONSTANTS } from '../DashboardChart.style' +import type { MauStatEntry } from '../../types' + +// Recharts renders to canvas-like SVG that jsdom can't measure; stub the pieces +// down to markers that expose the computed data/axis props so the assertions +// target this component's month-gap-filling and tick math, not recharts itself. +jest.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: PropsWithChildren) => ( +
{children}
+ ), + AreaChart: ({ data, children }: PropsWithChildren<{ data: MauStatEntry[] }>) => ( +
+ {children} +
+ ), + Area: ({ dataKey }: { dataKey: string }) =>
, + XAxis: () =>
, + YAxis: ({ domain, ticks }: { domain: number[]; ticks: number[] }) => ( +
+ ), + Tooltip: () =>
, + CartesianGrid: () =>
, +})) + +jest.mock('../TooltipDesign', () => ({ __esModule: true, default: () => null })) +jest.mock('@mui/material', () => ({ GlobalStyles: () => null })) +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }) })) + +const entry = (month: number, over: Partial = {}): MauStatEntry => + ({ + month, + mau: 0, + client_credentials_access_token_count: 0, + authz_code_access_token_count: 0, + authz_code_idtoken_count: 0, + ...over, + }) as MauStatEntry + +const renderChart = (props: Partial>) => + render( + , + ) + +const getData = () => JSON.parse(screen.getByTestId('area-chart').getAttribute('data-data') || '[]') +const getTicks = () => JSON.parse(screen.getByTestId('yaxis').getAttribute('data-ticks') || '[]') +const getDomain = () => JSON.parse(screen.getByTestId('yaxis').getAttribute('data-domain') || '[]') + +describe('DashboardChart', () => { + it('renders no data points when statData is null', () => { + // The component guards against a null feed even though the prop type is non-null. + const nullStatData = null as MauStatEntry[] | null as MauStatEntry[] + renderChart({ statData: nullStatData }) + expect(getData()).toEqual([]) + }) + + it('renders no data points when the start month is after the end month', () => { + renderChart({ statData: [entry(202401)], startMonth: '202403', endMonth: '202401' }) + expect(getData()).toEqual([]) + }) + + it('produces one point per month across the inclusive range', () => { + renderChart({ startMonth: '202401', endMonth: '202403', statData: [] }) + expect(getData()).toHaveLength(3) + }) + + it('fills months with no matching entry with zeroed counts', () => { + renderChart({ startMonth: '202401', endMonth: '202402', statData: [entry(202402, { mau: 5 })] }) + const data = getData() + expect(data[0]).toMatchObject({ month: 202401, mau: 0 }) + expect(data[1]).toMatchObject({ month: 202402, mau: 5 }) + }) + + it('carries a formatted month label onto each point', () => { + renderChart({ startMonth: '202401', endMonth: '202401', statData: [entry(202401)] }) + expect(getData()[0].monthLabel).toBe('2024 01') + }) + + it('rounds the Y max up to the next tick interval above the summed token counts', () => { + // Sum of the three token counts = 1500 -> already a multiple of 300, so 1500. + renderChart({ + startMonth: '202401', + endMonth: '202401', + statData: [ + entry(202401, { + authz_code_idtoken_count: 500, + authz_code_access_token_count: 500, + client_credentials_access_token_count: 500, + }), + ], + }) + expect(getDomain()).toEqual([0, 1500]) + }) + + it('never drops the Y max below the configured minimum', () => { + renderChart({ + startMonth: '202401', + endMonth: '202401', + statData: [entry(202401, { mau: 10 })], + }) + expect(getDomain()[1]).toBe(CHART_CONSTANTS.MIN_MAX) + }) + + it('generates ticks at the configured interval up to the max', () => { + renderChart({ startMonth: '202401', endMonth: '202401', statData: [] }) + const ticks = getTicks() + expect(ticks[0]).toBe(0) + expect(ticks[1]).toBe(CHART_CONSTANTS.TICK_INTERVAL) + expect(ticks[ticks.length - 1]).toBe(CHART_CONSTANTS.MIN_MAX) + }) + + it('renders one Area per legend series', () => { + renderChart({ startMonth: '202401', endMonth: '202401', statData: [] }) + expect(screen.getAllByTestId('area')).toHaveLength(3) + }) +}) diff --git a/admin-ui/app/routes/Dashboards/components/__tests__/SummaryCard.test.tsx b/admin-ui/app/routes/Dashboards/components/__tests__/SummaryCard.test.tsx new file mode 100644 index 0000000000..85d03a84ba --- /dev/null +++ b/admin-ui/app/routes/Dashboards/components/__tests__/SummaryCard.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import { SummaryCard } from '../SummaryCard' + +const classes = { summary: 'summary', summaryText: 'summaryText', summaryValue: 'summaryValue' } + +const renderCard = (value: number | null, text = 'Active Users') => + render(, { wrapper: AppTestWrapper }) + +describe('SummaryCard', () => { + it('renders the label text', () => { + renderCard(42) + expect(screen.getByText('Active Users')).toBeInTheDocument() + }) + + it('renders a numeric value', () => { + renderCard(42) + expect(screen.getByText('42')).toBeInTheDocument() + }) + + it('renders zero as a value rather than the dash fallback', () => { + renderCard(0) + expect(screen.getByText('0')).toBeInTheDocument() + expect(screen.queryByText('—')).not.toBeInTheDocument() + }) + + it('renders the em-dash fallback when the value is null', () => { + renderCard(null) + expect(screen.getByText('—')).toBeInTheDocument() + }) +}) diff --git a/admin-ui/app/routes/License/LicenseDetailsPage.test.tsx b/admin-ui/app/routes/License/__tests__/LicenseDetailsPage.test.tsx similarity index 89% rename from admin-ui/app/routes/License/LicenseDetailsPage.test.tsx rename to admin-ui/app/routes/License/__tests__/LicenseDetailsPage.test.tsx index c1ebc589ea..ca4027d6df 100644 --- a/admin-ui/app/routes/License/LicenseDetailsPage.test.tsx +++ b/admin-ui/app/routes/License/__tests__/LicenseDetailsPage.test.tsx @@ -2,8 +2,8 @@ import { type ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' -import LicenseDetailsPage from './LicenseDetailsPage' -import AppTestWrapper from '../Apps/Gluu/Tests/Components/AppTestWrapper' +import LicenseDetailsPage from '../LicenseDetailsPage' +import AppTestWrapper from '../../Apps/Gluu/Tests/Components/AppTestWrapper' jest.mock('@/cedarling', () => ({ useCedarling: () => ({ @@ -24,7 +24,7 @@ jest.mock('@/cedarling/utility', () => { } }) -jest.mock('../Apps/Gluu/GluuCommitDialog', () => ({ +jest.mock('../../Apps/Gluu/GluuCommitDialog', () => ({ __esModule: true, default: () => null, })) @@ -45,7 +45,7 @@ const mockLicense = { validityPeriod: '2022-10-01T00:00Z', } -jest.mock('./hooks/useLicenseDetails', () => ({ +jest.mock('../hooks/useLicenseDetails', () => ({ useLicenseDetails: () => ({ item: mockLicense, loading: false, diff --git a/admin-ui/app/routes/Pages/__tests__/ByeBye.test.tsx b/admin-ui/app/routes/Pages/__tests__/ByeBye.test.tsx new file mode 100644 index 0000000000..684fe59648 --- /dev/null +++ b/admin-ui/app/routes/Pages/__tests__/ByeBye.test.tsx @@ -0,0 +1,150 @@ +import { type PropsWithChildren } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import ByeBye from '../ByeBye' + +type ByeByeState = { + authReducer: { + config: { endSessionEndpoint: string; postLogoutRedirectUri: string } + hasSession: boolean + } +} +type AuthStatePayload = { state: boolean } + +// jest hoists mock factories above imports; factory-closed variables must be `mock`-prefixed. +const mockDispatch = jest.fn() +const mockState: ByeByeState = { + authReducer: { + config: { + endSessionEndpoint: 'https://idp.example.com/end', + postLogoutRedirectUri: 'https://app.example.com/', + }, + hasSession: true, + }, +} +jest.mock('@/redux/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: (fn: (s: ByeByeState) => T) => fn(mockState), +})) + +const mockDeleteSession = jest.fn() +jest.mock('Redux/api/backend-api', () => ({ deleteAdminUiSession: () => mockDeleteSession() })) +jest.mock('Redux/features/logoutSlice', () => ({ logoutUser: () => ({ type: 'logoutUser' }) })) +jest.mock('../../../redux/features/authSlice', () => ({ + setAuthState: (p: AuthStatePayload) => ({ type: 'setAuthState', payload: p }), +})) +jest.mock('Utils/Util', () => ({ uuidv4: () => 'fixed-state' })) +jest.mock('@/utils/logger', () => ({ logger: { error: jest.fn(), info: jest.fn() } })) + +const mockBuildSafeLogoutUrl = jest.fn() +const mockBuildSafeNavigationUrl = jest.fn() +jest.mock('@/utils/urlSecurity', () => ({ + buildSafeLogoutUrl: (endpoint: string, redirectUri: string, state: string) => + mockBuildSafeLogoutUrl(endpoint, redirectUri, state), + buildSafeNavigationUrl: (rawUrl: string) => mockBuildSafeNavigationUrl(rawUrl), +})) + +// The component renders inside Components' EmptyLayout/Label; stub to plain markup. +jest.mock('Components', () => ({ + EmptyLayout: { + Section: ({ children }: PropsWithChildren) =>
{children}
, + }, + Label: ({ children }: PropsWithChildren) =>
{children}
, +})) +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }) })) +jest.mock('Context/theme/themeContext', () => { + const { createContext } = jest.requireActual('react') + return { ThemeContext: createContext({ state: { theme: 'light' } }) } +}) +jest.mock('@/context/theme/config', () => ({ + __esModule: true, + default: () => ({ background: '#fff', fontColor: '#000' }), +})) + +describe('ByeBye', () => { + beforeEach(() => { + mockDispatch.mockClear() + mockDeleteSession.mockReset().mockResolvedValue(undefined) + mockBuildSafeLogoutUrl.mockReset() + mockBuildSafeNavigationUrl.mockReset() + mockState.authReducer = { + config: { + endSessionEndpoint: 'https://idp.example.com/end', + postLogoutRedirectUri: 'https://app.example.com/', + }, + hasSession: true, + } + }) + + it('renders the goodbye message', () => { + mockBuildSafeLogoutUrl.mockReturnValue('https://idp.example.com/end?state=fixed-state') + render() + expect(screen.getByTestId('label')).toHaveTextContent('messages.thanks_for_using_admin_ui') + }) + + it('marks auth as logged out and dispatches logout', async () => { + mockBuildSafeLogoutUrl.mockReturnValue('https://idp.example.com/end?state=fixed-state') + render() + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'setAuthState', + payload: { state: false }, + }), + ) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'logoutUser' }) + }) + + it('deletes the admin UI session when one exists', async () => { + mockBuildSafeLogoutUrl.mockReturnValue('https://idp.example.com/end?state=fixed-state') + render() + await waitFor(() => expect(mockDeleteSession).toHaveBeenCalledTimes(1)) + }) + + it('skips session deletion when there is no session', async () => { + mockState.authReducer.hasSession = false + mockBuildSafeLogoutUrl.mockReturnValue('https://idp.example.com/end?state=fixed-state') + render() + await waitFor(() => expect(mockDispatch).toHaveBeenCalledWith({ type: 'logoutUser' })) + expect(mockDeleteSession).not.toHaveBeenCalled() + }) + + // window.location assignment is a no-op under jsdom, so the redirect branch is + // asserted through which safe-URL builder ran with which arguments. + it('builds the safe end-session URL when the config is complete', async () => { + mockBuildSafeLogoutUrl.mockReturnValue('https://idp.example.com/end?state=fixed-state') + render() + await waitFor(() => + expect(mockBuildSafeLogoutUrl).toHaveBeenCalledWith( + 'https://idp.example.com/end', + 'https://app.example.com/', + 'fixed-state', + ), + ) + expect(mockBuildSafeNavigationUrl).not.toHaveBeenCalled() + }) + + it('falls back to the safe navigation URL when no end-session endpoint is configured', async () => { + mockState.authReducer.config = { + endSessionEndpoint: '', + postLogoutRedirectUri: 'https://app.example.com/', + } as never + mockBuildSafeNavigationUrl.mockReturnValue('https://app.example.com/') + render() + await waitFor(() => + expect(mockBuildSafeNavigationUrl).toHaveBeenCalledWith('https://app.example.com/'), + ) + expect(mockBuildSafeLogoutUrl).not.toHaveBeenCalled() + }) + + it('still resolves the navigation fallback when the redirect URI is unsafe', async () => { + mockState.authReducer.config = { + endSessionEndpoint: '', + postLogoutRedirectUri: 'javascript:alert(1)', + } as never + mockBuildSafeNavigationUrl.mockReturnValue(null) + render() + await waitFor(() => + expect(mockBuildSafeNavigationUrl).toHaveBeenCalledWith('javascript:alert(1)'), + ) + expect(mockBuildSafeLogoutUrl).not.toHaveBeenCalled() + }) +}) diff --git a/admin-ui/app/routes/Pages/__tests__/ProtectRoutes.test.tsx b/admin-ui/app/routes/Pages/__tests__/ProtectRoutes.test.tsx new file mode 100644 index 0000000000..4da0d07a23 --- /dev/null +++ b/admin-ui/app/routes/Pages/__tests__/ProtectRoutes.test.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import ProtectedRoute from '../ProtectRoutes' + +type AuthState = { authReducer: { isAuthenticated: boolean } } + +const mockSelector = jest.fn() +jest.mock('@/redux/hooks', () => ({ + useAppSelector: (fn: (s: AuthState) => T) => mockSelector(fn), +})) +jest.mock('@/helpers/navigation', () => ({ ROUTES: { ROOT: '/' } })) +jest.mock('react-router-dom', () => ({ + Navigate: ({ to }: { to: string }) =>
, +})) + +const setAuthenticated = (isAuthenticated: boolean) => + mockSelector.mockImplementation((fn: (s: AuthState) => T) => + fn({ authReducer: { isAuthenticated } }), + ) + +describe('ProtectedRoute', () => { + it('renders the protected children when authenticated', () => { + setAuthenticated(true) + render( + +
secret
+
, + ) + expect(screen.getByTestId('secret')).toBeInTheDocument() + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument() + }) + + it('redirects to the root route when not authenticated', () => { + setAuthenticated(false) + render( + +
secret
+
, + ) + expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/') + expect(screen.queryByTestId('secret')).not.toBeInTheDocument() + }) +}) diff --git a/admin-ui/app/utils/__tests__/RouteLoader.test.tsx b/admin-ui/app/utils/__tests__/RouteLoader.test.tsx new file mode 100644 index 0000000000..3241cc946d --- /dev/null +++ b/admin-ui/app/utils/__tests__/RouteLoader.test.tsx @@ -0,0 +1,58 @@ +import React, { Suspense } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { createLazyRoute, LazyRoutes } from '../RouteLoader' + +describe('createLazyRoute', () => { + it('attaches a preload method that invokes the import function', async () => { + const Loaded = () =>
loaded
+ const importFn = jest.fn(() => Promise.resolve({ default: Loaded })) + const route = createLazyRoute(importFn) + + await route.preload() + expect(importFn).toHaveBeenCalledTimes(1) + }) + + it('produces a lazily-rendered component that resolves to the imported module', async () => { + const Loaded = () =>
lazy-content
+ const Route = createLazyRoute(() => Promise.resolve({ default: Loaded })) + + render( + loading
}> + + , + ) + expect(screen.getByText('loading')).toBeInTheDocument() + await waitFor(() => expect(screen.getByText('lazy-content')).toBeInTheDocument()) + }) + + it('reuses the same import function reference for preload', () => { + const importFn = jest.fn(() => Promise.resolve({ default: () => null })) + const route = createLazyRoute(importFn) + route.preload() + route.preload() + expect(importFn).toHaveBeenCalledTimes(2) + }) +}) + +describe('LazyRoutes registry', () => { + const expectedRoutes = [ + 'DashboardPage', + 'ProfilePage', + 'Gluu404Error', + 'ByeBye', + 'GluuNavBar', + 'DefaultSidebar', + 'GluuToast', + 'GluuWebhookExecutionDialog', + ] as const + + it.each(expectedRoutes)('exposes %s as a preloadable lazy route', (name) => { + const route = LazyRoutes[name as keyof typeof LazyRoutes] + expect(route).toBeDefined() + expect(typeof route.preload).toBe('function') + }) + + it('registers exactly the expected set of routes', () => { + expect(Object.keys(LazyRoutes).sort()).toEqual([...expectedRoutes].sort()) + }) +}) diff --git a/admin-ui/app/utils/__tests__/logger.test.ts b/admin-ui/app/utils/__tests__/logger.test.ts new file mode 100644 index 0000000000..744d8790fe --- /dev/null +++ b/admin-ui/app/utils/__tests__/logger.test.ts @@ -0,0 +1,72 @@ +import { logger } from '../logger' +import { saveLogLevel } from '../logLevel' + +// The gate reads the level from localStorage via getLogLevel, so drive the real +// level through saveLogLevel rather than mocking the accessor. +describe('logger', () => { + const spies = { + trace: jest.spyOn(console, 'trace').mockImplementation(() => {}), + debug: jest.spyOn(console, 'debug').mockImplementation(() => {}), + info: jest.spyOn(console, 'info').mockImplementation(() => {}), + warn: jest.spyOn(console, 'warn').mockImplementation(() => {}), + error: jest.spyOn(console, 'error').mockImplementation(() => {}), + } + + beforeEach(() => { + window.localStorage.clear() + Object.values(spies).forEach((s) => s.mockClear()) + }) + afterAll(() => Object.values(spies).forEach((s) => s.mockRestore())) + + it('emits a message at the configured level', () => { + saveLogLevel('INFO') + logger.info('hello') + expect(spies.info).toHaveBeenCalledWith('[INFO]', 'hello') + }) + + it('emits a message above the configured level', () => { + saveLogLevel('INFO') + logger.error('boom') + expect(spies.error).toHaveBeenCalledWith('[ERROR]', 'boom') + }) + + it('suppresses a message below the configured level', () => { + saveLogLevel('WARN') + logger.info('quiet') + expect(spies.info).not.toHaveBeenCalled() + }) + + it('routes each level to its matching console method', () => { + saveLogLevel('TRACE') + logger.trace('t') + logger.debug('d') + logger.warn('w') + logger.error('e') + expect(spies.trace).toHaveBeenCalledWith('[TRACE]', 't') + expect(spies.debug).toHaveBeenCalledWith('[DEBUG]', 'd') + expect(spies.warn).toHaveBeenCalledWith('[WARN]', 'w') + expect(spies.error).toHaveBeenCalledWith('[ERROR]', 'e') + }) + + it('emits ERROR but drops lower levels when the level is ERROR', () => { + saveLogLevel('ERROR') + logger.warn('w') + logger.error('e') + expect(spies.warn).not.toHaveBeenCalled() + expect(spies.error).toHaveBeenCalledWith('[ERROR]', 'e') + }) + + it('defaults to INFO when no level is stored', () => { + window.localStorage.clear() + logger.debug('below-default') + logger.info('at-default') + expect(spies.debug).not.toHaveBeenCalled() + expect(spies.info).toHaveBeenCalledWith('[INFO]', 'at-default') + }) + + it('forwards multiple arguments to the console method', () => { + saveLogLevel('DEBUG') + logger.debug('ctx', { id: 1 }, 42) + expect(spies.debug).toHaveBeenCalledWith('[DEBUG]', 'ctx', { id: 1 }, 42) + }) +}) diff --git a/admin-ui/app/utils/__tests__/triggerWebhookForFeature.test.ts b/admin-ui/app/utils/__tests__/triggerWebhookForFeature.test.ts new file mode 100644 index 0000000000..15273deaa9 --- /dev/null +++ b/admin-ui/app/utils/__tests__/triggerWebhookForFeature.test.ts @@ -0,0 +1,57 @@ +import type { JsonValue } from 'Routes/Apps/Gluu/types/common' + +type WebhookPayload = { createdFeatureValue: Record; feature: string } +type WebhookAction = { type: string; payload: WebhookPayload } + +// mock-prefixed so the hoisted jest.mock factories may close over them. +const mockDispatch = jest.fn() +jest.mock('Redux/store', () => ({ + __esModule: true, + default: { dispatch: (action: WebhookAction) => mockDispatch(action) }, +})) + +const mockTriggerWebhook = jest.fn((payload: WebhookPayload) => ({ + type: 'triggerWebhook', + payload, +})) +jest.mock('Plugins/admin/redux/features/WebhookSlice', () => ({ + triggerWebhook: (payload: WebhookPayload) => mockTriggerWebhook(payload), +})) + +const mockLoggerError = jest.fn() +jest.mock('@/utils/logger', () => ({ + logger: { error: (msg: string, err?: Error) => mockLoggerError(msg, err) }, +})) + +import { triggerWebhookForFeature } from '@/utils/triggerWebhookForFeature' + +describe('triggerWebhookForFeature', () => { + beforeEach(() => { + mockDispatch.mockReset() + mockTriggerWebhook.mockClear() + mockLoggerError.mockReset() + }) + + it('dispatches the webhook action with the feature data and name', () => { + triggerWebhookForFeature({ id: 'abc' }, 'user-created') + expect(mockTriggerWebhook).toHaveBeenCalledWith({ + createdFeatureValue: { id: 'abc' }, + feature: 'user-created', + }) + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'triggerWebhook', + payload: { createdFeatureValue: { id: 'abc' }, feature: 'user-created' }, + }) + }) + + it('swallows dispatch errors and logs them with the feature name', () => { + mockDispatch.mockImplementation(() => { + throw new Error('dispatch failed') + }) + expect(() => triggerWebhookForFeature({}, 'scope-updated')).not.toThrow() + expect(mockLoggerError).toHaveBeenCalledWith( + expect.stringContaining('scope-updated'), + expect.any(Error), + ) + }) +}) diff --git a/admin-ui/plugins/__tests__/PluginListenersResolver.test.ts b/admin-ui/plugins/__tests__/PluginListenersResolver.test.ts new file mode 100644 index 0000000000..47998f0970 --- /dev/null +++ b/admin-ui/plugins/__tests__/PluginListenersResolver.test.ts @@ -0,0 +1,34 @@ +// Control the plugin list and metadata so listener wiring can be asserted +// without touching Vite's glob loader. +jest.mock('../../plugins.config.json', () => [ + { order: 2, key: 'b', metadataFile: './b/plugin-metadata' }, + { order: 1, key: 'a', metadataFile: './a/plugin-metadata' }, +]) + +const mockLoad = jest.fn() +jest.mock('../internal', () => ({ loadPluginMetadata: (p: string) => mockLoad(p) })) + +import processListeners from '../PluginListenersResolver' + +beforeEach(() => mockLoad.mockReset()) + +describe('PluginListenersResolver', () => { + it('invokes every plugin listener setup with the startListening handle', () => { + const startListening = jest.fn() + const setupA = jest.fn() + const setupB = jest.fn() + mockLoad.mockImplementation((p: string) => + p.includes('/b/') + ? { default: { listeners: [setupB] } } + : { default: { listeners: [setupA] } }, + ) + processListeners(startListening as never) + expect(setupA).toHaveBeenCalledWith(startListening) + expect(setupB).toHaveBeenCalledWith(startListening) + }) + + it('is a no-op when plugins declare no listeners', () => { + mockLoad.mockReturnValue({ default: {} }) + expect(() => processListeners(jest.fn() as never)).not.toThrow() + }) +}) diff --git a/admin-ui/plugins/__tests__/PluginMenuResolver.test.ts b/admin-ui/plugins/__tests__/PluginMenuResolver.test.ts new file mode 100644 index 0000000000..204e7a60f7 --- /dev/null +++ b/admin-ui/plugins/__tests__/PluginMenuResolver.test.ts @@ -0,0 +1,85 @@ +// Control the plugin list and metadata so the menu/route aggregation, sort and +// error handling can be asserted without touching Vite's glob loader. +jest.mock('../../plugins.config.json', () => [ + { order: 2, key: 'b', metadataFile: './b/plugin-metadata' }, + { order: 1, key: 'a', metadataFile: './a/plugin-metadata' }, +]) + +const mockLoad = jest.fn() +const mockLoadAsync = jest.fn() +jest.mock('../internal', () => ({ + loadPluginMetadata: (p: string) => mockLoad(p), + loadPluginMetadataAsync: (p: string) => mockLoadAsync(p), +})) + +const mockLoggerError = jest.fn() +jest.mock('@/utils/logger', () => ({ + logger: { error: (msg: string, err?: Error) => mockLoggerError(msg, err) }, +})) + +import { processMenus, processRoutes, processRoutesSync } from '../PluginMenuResolver' + +beforeEach(() => { + mockLoad.mockReset() + mockLoadAsync.mockReset() + mockLoggerError.mockReset() +}) + +describe('processMenus', () => { + it('aggregates menus from every plugin and sorts them by order', async () => { + mockLoadAsync.mockImplementation((p: string) => + p.includes('/b/') + ? { default: { menus: [{ title: 'B', order: 5 }] } } + : { default: { menus: [{ title: 'A', order: 1 }] } }, + ) + const menus = await processMenus() + expect(menus.map((m) => (m as { title: string }).title)).toEqual(['A', 'B']) + }) + + it('treats a plugin with no menus as an empty contribution', async () => { + mockLoadAsync.mockResolvedValue({ default: {} }) + expect(await processMenus()).toEqual([]) + }) + + it('logs and skips a plugin whose metadata fails to load, keeping the rest', async () => { + mockLoadAsync.mockImplementation((p: string) => + p.includes('/b/') + ? Promise.reject(new Error('boom')) + : Promise.resolve({ default: { menus: [{ title: 'A', order: 1 }] } }), + ) + const menus = await processMenus() + expect(menus).toHaveLength(1) + expect(mockLoggerError).toHaveBeenCalledWith( + expect.stringContaining('./b/plugin-metadata'), + expect.any(Error), + ) + }) +}) + +describe('processRoutes', () => { + it('aggregates routes from every plugin', async () => { + mockLoadAsync.mockResolvedValue({ default: { routes: [{ path: '/x' }] } }) + expect(await processRoutes()).toHaveLength(2) + }) + + it('logs and skips a plugin whose routes fail to load', async () => { + mockLoadAsync.mockImplementation((p: string) => + p.includes('/b/') + ? Promise.reject(new Error('boom')) + : Promise.resolve({ default: { routes: [{ path: '/a' }] } }), + ) + expect(await processRoutes()).toEqual([{ path: '/a' }]) + expect(mockLoggerError).toHaveBeenCalled() + }) +}) + +describe('processRoutesSync', () => { + it('aggregates routes synchronously and tolerates a throwing plugin', () => { + mockLoad.mockImplementation((p: string) => { + if (p.includes('/b/')) throw new Error('sync boom') + return { default: { routes: [{ path: '/a' }] } } + }) + expect(processRoutesSync()).toEqual([{ path: '/a' }]) + expect(mockLoggerError).toHaveBeenCalled() + }) +}) diff --git a/admin-ui/plugins/__tests__/PluginReducersResolver.test.ts b/admin-ui/plugins/__tests__/PluginReducersResolver.test.ts new file mode 100644 index 0000000000..94c53972cf --- /dev/null +++ b/admin-ui/plugins/__tests__/PluginReducersResolver.test.ts @@ -0,0 +1,50 @@ +// Control the plugin list and metadata so reducer registration/dedup can be +// asserted without touching Vite's glob loader or the real store. +jest.mock('../../plugins.config.json', () => [ + { order: 2, key: 'b', metadataFile: './b/plugin-metadata' }, + { order: 1, key: 'a', metadataFile: './a/plugin-metadata' }, +]) + +const mockLoad = jest.fn() +jest.mock('../internal', () => ({ loadPluginMetadata: (p: string) => mockLoad(p) })) + +const mockRegister = jest.fn() +jest.mock('Redux/reducers/ReducerRegistry', () => ({ + __esModule: true, + default: { register: (name: string, reducer: jest.Mock) => mockRegister(name, reducer) }, +})) + +import processReducers from '../PluginReducersResolver' + +beforeEach(() => { + mockLoad.mockReset() + mockRegister.mockReset() +}) + +describe('PluginReducersResolver', () => { + it('registers each plugin reducer exactly once, de-duplicating by name', () => { + const r1 = jest.fn() + const r2 = jest.fn() + mockLoad.mockImplementation((p: string) => + p.includes('/b/') + ? { default: { reducers: [{ name: 'shared', reducer: r2 }] } } // duplicate name + : { + default: { + reducers: [ + { name: 'shared', reducer: r1 }, + { name: 'unique', reducer: r2 }, + ], + }, + }, + ) + processReducers() + const names = mockRegister.mock.calls.map((c) => c[0]) + expect(names).toEqual(['shared', 'unique']) + }) + + it('does nothing when plugins expose no reducers', () => { + mockLoad.mockReturnValue({ default: {} }) + processReducers() + expect(mockRegister).not.toHaveBeenCalled() + }) +}) diff --git a/admin-ui/plugins/admin/__tests__/components/Asset/JansAssetAddPage.test.tsx b/admin-ui/plugins/admin/__tests__/components/Assets/Asset/JansAssetAddPage.test.tsx similarity index 100% rename from admin-ui/plugins/admin/__tests__/components/Asset/JansAssetAddPage.test.tsx rename to admin-ui/plugins/admin/__tests__/components/Assets/Asset/JansAssetAddPage.test.tsx diff --git a/admin-ui/plugins/admin/__tests__/components/Asset/JansAssetEditPage.test.tsx b/admin-ui/plugins/admin/__tests__/components/Assets/Asset/JansAssetEditPage.test.tsx similarity index 100% rename from admin-ui/plugins/admin/__tests__/components/Asset/JansAssetEditPage.test.tsx rename to admin-ui/plugins/admin/__tests__/components/Assets/Asset/JansAssetEditPage.test.tsx diff --git a/admin-ui/plugins/admin/__tests__/components/Asset/JansAssetListPage.test.tsx b/admin-ui/plugins/admin/__tests__/components/Assets/Asset/JansAssetListPage.test.tsx similarity index 100% rename from admin-ui/plugins/admin/__tests__/components/Asset/JansAssetListPage.test.tsx rename to admin-ui/plugins/admin/__tests__/components/Assets/Asset/JansAssetListPage.test.tsx diff --git a/admin-ui/plugins/admin/__tests__/components/Asset/assetCedarTestConstants.ts b/admin-ui/plugins/admin/__tests__/components/Assets/Asset/assetCedarTestConstants.ts similarity index 100% rename from admin-ui/plugins/admin/__tests__/components/Asset/assetCedarTestConstants.ts rename to admin-ui/plugins/admin/__tests__/components/Assets/Asset/assetCedarTestConstants.ts diff --git a/admin-ui/plugins/admin/__tests__/components/Asset/assetTestUtils.tsx b/admin-ui/plugins/admin/__tests__/components/Assets/Asset/assetTestUtils.tsx similarity index 100% rename from admin-ui/plugins/admin/__tests__/components/Asset/assetTestUtils.tsx rename to admin-ui/plugins/admin/__tests__/components/Assets/Asset/assetTestUtils.tsx diff --git a/admin-ui/plugins/admin/components/Health/components/__tests__/ServiceStatusCard.test.tsx b/admin-ui/plugins/admin/components/Health/components/__tests__/ServiceStatusCard.test.tsx new file mode 100644 index 0000000000..157e8983e4 --- /dev/null +++ b/admin-ui/plugins/admin/components/Health/components/__tests__/ServiceStatusCard.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import i18n from '@/i18n' +import ServiceStatusCard from '../ServiceStatusCard' +import type { ServiceHealth } from '../../types' + +const renderCard = (service: ServiceHealth, isDark = false) => + render(, { wrapper: AppTestWrapper }) + +describe('ServiceStatusCard', () => { + it('formats a hyphenated service name into title case', () => { + renderCard({ name: 'jans-auth', status: 'up' }) + // formatServiceName: 'jans-auth' -> 'Jans Auth' + expect(screen.getByText('Jans Auth')).toBeInTheDocument() + }) + + it('shows the running message for an up service (trailing period stripped)', () => { + renderCard({ name: 'auth', status: 'up' }) + const expected = i18n.t('messages.service_status_up').replace(/\.$/, '') + expect(screen.getByText(expected)).toBeInTheDocument() + }) + + it('shows the unavailable message for a down service', () => { + renderCard({ name: 'auth', status: 'down' }) + const expected = i18n.t('messages.service_status_down').replace(/\.$/, '') + expect(screen.getByText(expected)).toBeInTheDocument() + }) + + it('shows the degraded message for a degraded service', () => { + renderCard({ name: 'auth', status: 'degraded' }) + const expected = i18n.t('messages.service_status_degraded').replace(/\.$/, '') + expect(screen.getByText(expected)).toBeInTheDocument() + }) + + it('prefers an explicit service error over the status message', () => { + renderCard({ name: 'auth', status: 'down', error: 'Connection refused' }) + expect(screen.getByText('Connection refused')).toBeInTheDocument() + expect( + screen.queryByText(i18n.t('messages.service_status_down').replace(/\.$/, '')), + ).not.toBeInTheDocument() + }) + + it('renders the localized status badge label', () => { + renderCard({ name: 'auth', status: 'up' }) + expect(screen.getByText(i18n.t('messages.status_active'))).toBeInTheDocument() + }) + + it('exposes a per-service test id derived from the raw name', () => { + renderCard({ name: 'jans-config-api', status: 'up' }) + expect(screen.getByTestId('service-card-jans-config-api')).toBeInTheDocument() + }) +}) diff --git a/admin-ui/plugins/admin/components/MAU/components/__tests__/MauTrendChart.test.tsx b/admin-ui/plugins/admin/components/MAU/components/__tests__/MauTrendChart.test.tsx new file mode 100644 index 0000000000..0691f12821 --- /dev/null +++ b/admin-ui/plugins/admin/components/MAU/components/__tests__/MauTrendChart.test.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import i18n from '@/i18n' +import MauTrendChart from '../MauTrendChart' +import type { MauStatEntry } from '../../types' + +jest.mock('recharts', () => { + const actual = jest.requireActual('recharts') + const reactLib = jest.requireActual('react') as typeof import('react') + return { + ...actual, + ResponsiveContainer: ({ + children, + }: { + children: React.ReactElement<{ width?: number; height?: number }> + }) => reactLib.cloneElement(children, { width: 800, height: 400 }), + } +}) + +const entry = (month: number, mau: number): MauStatEntry => ({ + month, + mau, + client_credentials_access_token_count: 0, + authz_code_access_token_count: 0, + authz_code_idtoken_count: 0, +}) + +const renderChart = (data: MauStatEntry[]) => + render(, { wrapper: AppTestWrapper }) + +describe('MauTrendChart', () => { + it('renders the localized trend title', () => { + renderChart([entry(202401, 10)]) + expect(screen.getByText(i18n.t('titles.mau_trend'))).toBeInTheDocument() + }) + + it('renders formatted month labels on the axis', () => { + renderChart([entry(202401, 10)]) + // formatMonth(202401) -> 'Jan 2024' + expect(screen.getByText('Jan 2024')).toBeInTheDocument() + }) + + it('renders without crashing for an empty dataset', () => { + renderChart([]) + expect(screen.getByText(i18n.t('titles.mau_trend'))).toBeInTheDocument() + }) +}) diff --git a/admin-ui/plugins/admin/components/MAU/components/__tests__/TokenDistributionChart.test.tsx b/admin-ui/plugins/admin/components/MAU/components/__tests__/TokenDistributionChart.test.tsx new file mode 100644 index 0000000000..1cf237ad8f --- /dev/null +++ b/admin-ui/plugins/admin/components/MAU/components/__tests__/TokenDistributionChart.test.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import i18n from '@/i18n' +import TokenDistributionChart from '../TokenDistributionChart' +import type { MauSummary } from '../../types' + +jest.mock('recharts', () => { + const actual = jest.requireActual('recharts') + const reactLib = jest.requireActual('react') as typeof import('react') + return { + ...actual, + ResponsiveContainer: ({ + children, + }: { + children: React.ReactElement<{ width?: number; height?: number }> + }) => reactLib.cloneElement(children, { width: 800, height: 400 }), + } +}) + +const summary = (overrides: Partial = {}): MauSummary => ({ + totalMau: 0, + totalTokens: 0, + clientCredentialsTokens: 0, + authCodeTokens: 0, + mauChange: 0, + tokenChange: 0, + ...overrides, +}) + +const renderChart = (s: MauSummary) => + render(, { wrapper: AppTestWrapper }) + +describe('TokenDistributionChart', () => { + it('renders the localized distribution title', () => { + renderChart(summary({ totalTokens: 10 })) + expect(screen.getByText(i18n.t('titles.token_distribution'))).toBeInTheDocument() + }) + + it('renders the chart (not the empty state) when there are tokens', () => { + renderChart(summary({ totalTokens: 10, clientCredentialsTokens: 6, authCodeTokens: 4 })) + expect(screen.queryByText(i18n.t('messages.no_mau_data'))).not.toBeInTheDocument() + }) + + it('renders the empty state when there are no tokens', () => { + renderChart(summary({ totalTokens: 0 })) + expect(screen.getByText(i18n.t('messages.no_mau_data'))).toBeInTheDocument() + }) +}) diff --git a/admin-ui/plugins/admin/components/MAU/components/__tests__/TokenTrendChart.test.tsx b/admin-ui/plugins/admin/components/MAU/components/__tests__/TokenTrendChart.test.tsx new file mode 100644 index 0000000000..0b692a1bab --- /dev/null +++ b/admin-ui/plugins/admin/components/MAU/components/__tests__/TokenTrendChart.test.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import i18n from '@/i18n' +import TokenTrendChart from '../TokenTrendChart' +import type { MauStatEntry } from '../../types' + +jest.mock('recharts', () => { + const actual = jest.requireActual('recharts') + const reactLib = jest.requireActual('react') as typeof import('react') + return { + ...actual, + ResponsiveContainer: ({ + children, + }: { + children: React.ReactElement<{ width?: number; height?: number }> + }) => reactLib.cloneElement(children, { width: 800, height: 400 }), + } +}) + +const entry = (month: number): MauStatEntry => ({ + month, + mau: 5, + client_credentials_access_token_count: 3, + authz_code_access_token_count: 2, + authz_code_idtoken_count: 1, +}) + +const renderChart = (data: MauStatEntry[]) => + render(, { wrapper: AppTestWrapper }) + +describe('TokenTrendChart', () => { + it('renders the localized token-trends title', () => { + renderChart([entry(202403)]) + expect(screen.getByText(i18n.t('titles.token_trends'))).toBeInTheDocument() + }) + + it('renders formatted month labels for each data point', () => { + renderChart([entry(202403)]) + // formatMonth(202403) -> 'Mar 2024' + expect(screen.getByText('Mar 2024')).toBeInTheDocument() + }) + + it('renders without crashing for an empty dataset', () => { + renderChart([]) + expect(screen.getByText(i18n.t('titles.token_trends'))).toBeInTheDocument() + }) +}) diff --git a/admin-ui/plugins/admin/components/Webhook/__tests__/ShortcodePopover.test.tsx b/admin-ui/plugins/admin/components/Webhook/__tests__/ShortcodePopover.test.tsx new file mode 100644 index 0000000000..560e911a4a --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/__tests__/ShortcodePopover.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import i18n from '@/i18n' +import ShortcodePopover from '../ShortcodePopover' +import type { ShortcodePopoverProps } from '../types' + +const codes: ShortcodePopoverProps['codes'] = [ + { key: '{{user}}', label: 'User' }, + { key: '{{org}}', label: 'Organization' }, +] + +const setup = (overrides: Partial = {}) => { + const handleSelectShortcode = jest.fn() + const props: ShortcodePopoverProps = { codes, handleSelectShortcode, ...overrides } + render(, { wrapper: AppTestWrapper }) + return { handleSelectShortcode } +} + +// Before opening, the only button on screen is the popover trigger. +const getTrigger = () => screen.getAllByRole('button')[0] + +describe('ShortcodePopover', () => { + it('keeps the popover closed until the trigger is clicked', () => { + setup() + expect(screen.queryByText('User')).not.toBeInTheDocument() + }) + + it('opens the popover and lists the shortcode labels on click', () => { + setup() + fireEvent.click(getTrigger()) + expect(screen.getByText('User')).toBeInTheDocument() + expect(screen.getByText('Organization')).toBeInTheDocument() + }) + + it('emits the selected shortcode key and closes the popover', () => { + const { handleSelectShortcode } = setup() + fireEvent.click(getTrigger()) + fireEvent.click(screen.getByText('Organization')) + expect(handleSelectShortcode).toHaveBeenCalledWith('{{org}}') + }) + + it('does not open or emit when disabled', () => { + const { handleSelectShortcode } = setup({ disabled: true }) + fireEvent.click(getTrigger()) + expect(screen.queryByText('User')).not.toBeInTheDocument() + expect(handleSelectShortcode).not.toHaveBeenCalled() + }) + + it('shows the empty message when there are no codes', () => { + setup({ codes: [] }) + fireEvent.click(getTrigger()) + expect(screen.getByText(i18n.t('messages.no_shortcodes_found'))).toBeInTheDocument() + }) +}) diff --git a/admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/helpers.test.ts b/admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/__tests__/helpers.test.ts similarity index 91% rename from admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/helpers.test.ts rename to admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/__tests__/helpers.test.ts index a390574773..b79950e75b 100644 --- a/admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/helpers.test.ts +++ b/admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/__tests__/helpers.test.ts @@ -1,4 +1,4 @@ -import { generateLabel } from './helpers' +import { generateLabel } from '../helpers' describe('generateLabel', () => { it('title-cases camelCase keys without lowercasing inner words', () => { diff --git a/admin-ui/plugins/auth-server/components/AuthServerProperties/components/__tests__/DefaultAcrInput.test.tsx b/admin-ui/plugins/auth-server/components/AuthServerProperties/components/__tests__/DefaultAcrInput.test.tsx new file mode 100644 index 0000000000..891052d28c --- /dev/null +++ b/admin-ui/plugins/auth-server/components/AuthServerProperties/components/__tests__/DefaultAcrInput.test.tsx @@ -0,0 +1,57 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import DefaultAcrInput from '../DefaultAcrInput' +import type { DefaultAcrInputProps } from '../../types' + +const setup = (overrides: Partial = {}) => { + const handler = jest.fn() + const props: DefaultAcrInputProps = { + label: 'Default ACR', + name: 'defaultAcr', + value: 'simple_password_auth', + handler, + path: '/defaultAcr', + options: ['simple_password_auth', 'passkey'], + ...overrides, + } + render(, { wrapper: AppTestWrapper }) + return { handler } +} + +const getSelect = () => screen.getByRole('combobox') as HTMLSelectElement + +describe('DefaultAcrInput', () => { + it('renders the provided value as the current selection', () => { + setup() + expect(getSelect().value).toBe('simple_password_auth') + }) + + it('renders string options as both value and label', () => { + setup({ value: '' }) + expect(screen.getByRole('option', { name: 'passkey' })).toBeInTheDocument() + }) + + it('renders object options using their explicit label', () => { + setup({ + value: '', + options: [{ value: 'pwd', label: 'Password' }], + }) + const option = screen.getByRole('option', { name: 'Password' }) as HTMLOptionElement + expect(option.value).toBe('pwd') + }) + + it('emits a replace operation when a non-empty value is selected', () => { + const { handler } = setup({ value: '' }) + fireEvent.change(getSelect(), { target: { value: 'passkey' } }) + expect(handler).toHaveBeenCalledWith({ path: '/defaultAcr', value: 'passkey', op: 'replace' }) + }) + + it('does not emit when the selected value is empty', () => { + const { handler } = setup({ + value: 'passkey', + options: ['', 'passkey'], + }) + fireEvent.change(getSelect(), { target: { value: '' } }) + expect(handler).not.toHaveBeenCalled() + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Ssa/components/__tests__/AvailableCustomAttributesPanel.test.tsx b/admin-ui/plugins/auth-server/components/Ssa/components/__tests__/AvailableCustomAttributesPanel.test.tsx new file mode 100644 index 0000000000..326844e08e --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Ssa/components/__tests__/AvailableCustomAttributesPanel.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import i18n from '@/i18n' +import AvailableCustomAttributesPanel from '../AvailableCustomAttributesPanel' +import type { CustomAttributesPanelProps } from '../../types' + +const setup = (overrides: Partial = {}) => { + const onAttributeSelect = jest.fn() + const onSearchChange = jest.fn() + const props: CustomAttributesPanelProps = { + availableAttributes: ['department', 'employeeNumber', 'costCenter'], + selectedAttributes: [], + onAttributeSelect, + searchInputValue: '', + onSearchChange, + ...overrides, + } + render(, { wrapper: AppTestWrapper }) + return { onAttributeSelect, onSearchChange } +} + +describe('AvailableCustomAttributesPanel', () => { + it('renders every available attribute when there is no search filter', () => { + setup() + expect(screen.getByRole('button', { name: /department/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /employeeNumber/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /costCenter/i })).toBeInTheDocument() + }) + + it('filters the list case-insensitively by the search value', () => { + setup({ searchInputValue: 'EMPLOYEE' }) + expect(screen.getByRole('button', { name: /employeeNumber/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /department/i })).not.toBeInTheDocument() + }) + + it('hides attributes that are already selected', () => { + setup({ selectedAttributes: ['department'] }) + expect(screen.queryByRole('button', { name: /^Add department$/i })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /costCenter/i })).toBeInTheDocument() + }) + + it('invokes onAttributeSelect with the chosen attribute', () => { + const { onAttributeSelect } = setup() + fireEvent.click(screen.getByRole('button', { name: /costCenter/i })) + expect(onAttributeSelect).toHaveBeenCalledWith('costCenter') + }) + + it('reports search input changes through onSearchChange', () => { + const { onSearchChange } = setup() + fireEvent.change(screen.getByPlaceholderText(i18n.t('placeholders.search_attribute_here')), { + target: { value: 'dep' }, + }) + expect(onSearchChange).toHaveBeenCalledWith('dep') + }) + + it('clears the search when the clear button is clicked', () => { + const { onSearchChange } = setup({ searchInputValue: 'dep' }) + fireEvent.click(screen.getByRole('button', { name: i18n.t('actions.clear_search') })) + expect(onSearchChange).toHaveBeenCalledWith('') + }) + + it('shows the no-data state when nothing matches the search', () => { + setup({ searchInputValue: 'zzz-no-match' }) + expect(screen.getByText(i18n.t('messages.no_data_found'))).toBeInTheDocument() + }) + + it('shows the no-data state when there are no available attributes', () => { + setup({ availableAttributes: [] }) + expect(screen.getByText(i18n.t('messages.no_data_found'))).toBeInTheDocument() + }) +}) diff --git a/admin-ui/plugins/auth-server/services/__tests__/jsonPropertiesService.test.ts b/admin-ui/plugins/auth-server/services/__tests__/jsonPropertiesService.test.ts new file mode 100644 index 0000000000..f3af6a78d5 --- /dev/null +++ b/admin-ui/plugins/auth-server/services/__tests__/jsonPropertiesService.test.ts @@ -0,0 +1,133 @@ +import type { JsonPatch } from 'JansConfigApi' +import type { UserAction } from 'Utils/types' + +type AuditPayload = { audit: boolean } +type ActionType = 'FETCH' | 'PATCH' | 'DELETION' +type AuditExtras = { action: UserAction } + +// Collaborators are covered by their own suites; stub them so the assertions +// target this service's audit action-type selection and error/403 handling. +const mockFetch = jest.fn() +const mockPatch = jest.fn() +jest.mock('../../api/jsonPropertiesClient', () => ({ + callFetchJsonProperties: () => mockFetch(), + callPatchJsonProperties: (patches: JsonPatch[]) => mockPatch(patches), +})) + +const mockPostUserAction = jest.fn() +jest.mock('Redux/api/backend-api', () => ({ + postUserAction: (a: AuditPayload) => mockPostUserAction(a), +})) + +const mockAddAdditionalData = jest.fn() +const mockIsForbidden = jest.fn((_e?: Error) => false) +jest.mock('Utils/TokenController', () => ({ + addAdditionalData: ( + audit: AuditPayload, + type: ActionType, + resource: string, + extras: AuditExtras, + ) => mockAddAdditionalData(audit, type, resource, extras), + isFourZeroThreeError: (e: Error) => mockIsForbidden(e), +})) + +const mockRedirect = jest.fn() +jest.mock('../../utils/sessionExpiredRedirect', () => ({ + redirectSessionExpired: () => mockRedirect(), +})) + +jest.mock('@/audit', () => ({ + FETCH: 'FETCH', + PATCH: 'PATCH', + DELETION: 'DELETION', + createSuccessAuditInit: () => ({ audit: true }), + getCurrentAuditContext: () => ({}), +})) +jest.mock('../../redux/utils/auditHelpers', () => ({ + enhanceJsonConfigAuditPayload: (payload: AuditExtras) => payload, +})) +jest.mock('../../redux/audit/Resources', () => ({ JSON_CONFIG: 'jsonConfig' })) +jest.mock('@/utils/logger', () => ({ logger: { error: jest.fn() } })) +jest.mock('@/utils/apiErrorMessage', () => ({ resolveApiErrorMessage: (e: Error) => e.message })) + +import { + fetchAuthServerJsonProperties, + patchAuthServerJsonProperties, +} from '../jsonPropertiesService' + +const actionType = () => mockAddAdditionalData.mock.calls[0][1] + +beforeEach(() => { + mockFetch.mockReset() + mockPatch.mockReset() + mockPostUserAction.mockReset().mockResolvedValue(undefined) + mockAddAdditionalData.mockReset() + mockIsForbidden.mockReset().mockReturnValue(false) + mockRedirect.mockReset() +}) + +describe('fetchAuthServerJsonProperties', () => { + it('returns the fetched config and posts a FETCH audit action', async () => { + mockFetch.mockResolvedValue({ some: 'config' }) + const data = await fetchAuthServerJsonProperties() + expect(data).toEqual({ some: 'config' }) + expect(actionType()).toBe('FETCH') + expect(mockPostUserAction).toHaveBeenCalledWith({ audit: true }) + }) + + it('rethrows and does not redirect on a non-403 failure', async () => { + mockFetch.mockRejectedValue(new Error('500')) + await expect(fetchAuthServerJsonProperties()).rejects.toThrow('500') + expect(mockRedirect).not.toHaveBeenCalled() + }) + + it('redirects to the session-expired flow on a 403 failure', async () => { + mockFetch.mockRejectedValue(new Error('403')) + mockIsForbidden.mockReturnValue(true) + await expect(fetchAuthServerJsonProperties()).rejects.toThrow('403') + expect(mockRedirect).toHaveBeenCalledTimes(1) + }) +}) + +describe('patchAuthServerJsonProperties', () => { + type PatchActionData = { deletedMapping?: boolean; requestBody?: JsonPatch[] } + const action = (action_data?: PatchActionData): UserAction => + ({ action_data }) as Partial as UserAction + + it('records a PATCH action for an ordinary replace patch', async () => { + mockPatch.mockResolvedValue({ ok: true }) + await patchAuthServerJsonProperties( + action({ requestBody: [{ op: 'replace', path: '/x', value: 1 }] }), + ) + expect(actionType()).toBe('PATCH') + expect(mockPatch).toHaveBeenCalledWith([{ op: 'replace', path: '/x', value: 1 }]) + }) + + it('records a DELETION action when a remove patch is present', async () => { + mockPatch.mockResolvedValue({}) + await patchAuthServerJsonProperties(action({ requestBody: [{ op: 'remove', path: '/y' }] })) + expect(actionType()).toBe('DELETION') + }) + + it('records a DELETION action when deletedMapping is set', async () => { + mockPatch.mockResolvedValue({}) + await patchAuthServerJsonProperties(action({ deletedMapping: true, requestBody: [] })) + expect(actionType()).toBe('DELETION') + }) + + it('defaults to an empty patch list when no request body is provided', async () => { + mockPatch.mockResolvedValue({}) + await patchAuthServerJsonProperties(action(undefined)) + expect(mockPatch).toHaveBeenCalledWith([]) + expect(actionType()).toBe('PATCH') + }) + + it('redirects on a 403 patch failure and rethrows', async () => { + mockPatch.mockRejectedValue(new Error('403')) + mockIsForbidden.mockReturnValue(true) + await expect( + patchAuthServerJsonProperties(action({ requestBody: [{ op: 'replace' }] })), + ).rejects.toThrow('403') + expect(mockRedirect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/admin-ui/plugins/scripts/__tests__/components/PersonAuthenticationFields.test.tsx b/admin-ui/plugins/scripts/__tests__/components/PersonAuthenticationFields.test.tsx new file mode 100644 index 0000000000..8d9fd568f4 --- /dev/null +++ b/admin-ui/plugins/scripts/__tests__/components/PersonAuthenticationFields.test.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import type { FormikProps } from 'formik' +import { PersonAuthenticationFields } from 'Plugins/scripts/components/PersonAuthenticationFields' +import { SAML_ACRS_OPTIONS, INTERACTIVE_OPTIONS } from 'Plugins/scripts/components/constants' +import type { FormValues } from 'Plugins/scripts/components/types/forms' + +// The Gluu form rows are exercised by their own suites; here we stub them down to +// the props this component drives so the assertions target the alias-toggle and +// usage_type wiring rather than the rendering of the shared rows. +jest.mock('Routes/Apps/Gluu/GluuLabel', () => ({ + __esModule: true, + default: ({ label }: { label: string }) => {label}, +})) + +type SelectRowProps = { + value: string + values: Array<{ value: string; label: string }> + formik: { handleChange: (e: { target: { value: string } }) => void } + showError?: boolean + errorMessage?: string +} + +jest.mock('Routes/Apps/Gluu/GluuSelectRow', () => ({ + __esModule: true, + default: ({ value, values, formik, showError, errorMessage }: SelectRowProps) => ( +
+ + {showError ? {errorMessage} : null} +
+ ), +})) + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +const baseFormik = (overrides: Partial> = {}): FormikProps => + ({ + values: { aliases: [], moduleProperties: [] } as Partial as FormValues, + errors: {}, + touched: {}, + handleBlur: jest.fn(), + setFieldValue: jest.fn(), + ...overrides, + }) as Partial> as FormikProps + +const setup = (props: Partial> = {}) => { + const usageTypeChange = jest.fn() + const getModuleProperty = jest.fn(() => 'interactive') + const formik = (props.formik as FormikProps) ?? baseFormik() + const utils = render( + , + ) + return { usageTypeChange, getModuleProperty, formik, ...utils } +} + +describe('PersonAuthenticationFields', () => { + it('renders one option per SAML ACR', () => { + setup() + SAML_ACRS_OPTIONS.forEach((acr) => { + expect(screen.getByText(acr)).toBeInTheDocument() + }) + }) + + it('adds an ACR to aliases on mousedown when it is not already selected', () => { + const setFieldValue = jest.fn() + const formik = baseFormik({ + values: { aliases: [], moduleProperties: [] } as Partial as FormValues, + setFieldValue, + }) + setup({ formik }) + fireEvent.mouseDown(screen.getByText(SAML_ACRS_OPTIONS[0])) + expect(setFieldValue).toHaveBeenCalledWith('aliases', [SAML_ACRS_OPTIONS[0]]) + }) + + it('removes an ACR from aliases on mousedown when it is already selected', () => { + const setFieldValue = jest.fn() + const formik = baseFormik({ + values: { + aliases: [SAML_ACRS_OPTIONS[0]], + moduleProperties: [], + } as Partial as FormValues, + setFieldValue, + }) + setup({ formik }) + fireEvent.mouseDown(screen.getByText(SAML_ACRS_OPTIONS[0])) + expect(setFieldValue).toHaveBeenCalledWith('aliases', []) + }) + + it('does not toggle aliases when viewOnly is set', () => { + const setFieldValue = jest.fn() + const formik = baseFormik({ setFieldValue }) + setup({ formik, viewOnly: true }) + fireEvent.mouseDown(screen.getByText(SAML_ACRS_OPTIONS[0])) + expect(setFieldValue).not.toHaveBeenCalled() + }) + + it('forwards the selected usage type to usageTypeChange', () => { + const { usageTypeChange } = setup() + fireEvent.change(screen.getByTestId('usage-type-select'), { + target: { value: INTERACTIVE_OPTIONS[1].value }, + }) + expect(usageTypeChange).toHaveBeenCalledWith(INTERACTIVE_OPTIONS[1].value) + }) + + it('reads the current usage type from module properties', () => { + const { getModuleProperty, formik } = setup() + expect(getModuleProperty).toHaveBeenCalledWith('usage_type', formik.values.moduleProperties) + }) + + it('surfaces the module property error only when the field is touched', () => { + const formik = baseFormik({ + errors: { moduleProperties: 'required' } as FormikProps['errors'], + // moduleProperties is an array field in FormikTouched, so a boolean flag + // requires a double assertion; the component only reads its truthiness. + touched: { moduleProperties: true } as object as FormikProps['touched'], + }) + setup({ formik }) + expect(screen.getByTestId('usage-type-error')).toHaveTextContent('required') + }) + + it('hides the error when the field is untouched', () => { + const formik = baseFormik({ + errors: { moduleProperties: 'required' } as FormikProps['errors'], + touched: {}, + }) + setup({ formik }) + expect(screen.queryByTestId('usage-type-error')).not.toBeInTheDocument() + }) +}) diff --git a/admin-ui/plugins/services/__tests__/components/CacheInMemory.test.tsx b/admin-ui/plugins/services/__tests__/components/CacheInMemory.test.tsx index 3280263891..b43c4b330f 100644 --- a/admin-ui/plugins/services/__tests__/components/CacheInMemory.test.tsx +++ b/admin-ui/plugins/services/__tests__/components/CacheInMemory.test.tsx @@ -2,8 +2,8 @@ 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' +import CacheInMemory from 'Plugins/services/components/CacheInMemory' +import type { CacheFormValues } from 'Plugins/services/components/types' const classes: Record = { sectionGrid: 'sectionGrid', diff --git a/admin-ui/plugins/services/__tests__/components/CacheMemcached.test.tsx b/admin-ui/plugins/services/__tests__/components/CacheMemcached.test.tsx index 3854495a3d..550c9cb0f8 100644 --- a/admin-ui/plugins/services/__tests__/components/CacheMemcached.test.tsx +++ b/admin-ui/plugins/services/__tests__/components/CacheMemcached.test.tsx @@ -2,8 +2,8 @@ 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' +import CacheMemcached from 'Plugins/services/components/CacheMemcached' +import type { CacheFormValues } from 'Plugins/services/components/types' const classes: Record = { sectionGrid: 'sectionGrid', diff --git a/admin-ui/plugins/services/__tests__/components/CacheNative.test.tsx b/admin-ui/plugins/services/__tests__/components/CacheNative.test.tsx index 8d16559970..199d8b6234 100644 --- a/admin-ui/plugins/services/__tests__/components/CacheNative.test.tsx +++ b/admin-ui/plugins/services/__tests__/components/CacheNative.test.tsx @@ -2,8 +2,8 @@ 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' +import CacheNative from 'Plugins/services/components/CacheNative' +import type { CacheFormValues } from 'Plugins/services/components/types' const classes: Record = { sectionGrid: 'sectionGrid', diff --git a/admin-ui/plugins/services/__tests__/components/CachePage.test.tsx b/admin-ui/plugins/services/__tests__/components/CachePage.test.tsx index 9e878d546c..f14ab7a4b2 100644 --- a/admin-ui/plugins/services/__tests__/components/CachePage.test.tsx +++ b/admin-ui/plugins/services/__tests__/components/CachePage.test.tsx @@ -4,7 +4,7 @@ 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 CachePage from 'Plugins/services/Components/CachePage' +import CachePage from 'Plugins/services/components/CachePage' const mockMutate = jest.fn() diff --git a/admin-ui/plugins/services/__tests__/components/CacheRedis.test.tsx b/admin-ui/plugins/services/__tests__/components/CacheRedis.test.tsx index e2a17994dd..55d1598305 100644 --- a/admin-ui/plugins/services/__tests__/components/CacheRedis.test.tsx +++ b/admin-ui/plugins/services/__tests__/components/CacheRedis.test.tsx @@ -2,8 +2,8 @@ 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' +import CacheRedis from 'Plugins/services/components/CacheRedis' +import type { CacheFormValues } from 'Plugins/services/components/types' const classes: Record = { sectionGrid: 'sectionGrid', diff --git a/admin-ui/plugins/services/__tests__/components/PersistenceDetail.test.tsx b/admin-ui/plugins/services/__tests__/components/PersistenceDetail.test.tsx index fc813f85ac..ab76befb9a 100644 --- a/admin-ui/plugins/services/__tests__/components/PersistenceDetail.test.tsx +++ b/admin-ui/plugins/services/__tests__/components/PersistenceDetail.test.tsx @@ -4,7 +4,7 @@ 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 PersistenceDetail from 'Plugins/services/Components/PersistenceDetail' +import PersistenceDetail from 'Plugins/services/components/PersistenceDetail' jest.mock('@/cedarling', () => ({ useCedarling: jest.fn(() => ({ diff --git a/admin-ui/plugins/services/__tests__/components/hooks/useCacheAudit.test.ts b/admin-ui/plugins/services/__tests__/components/hooks/useCacheAudit.test.ts index 9d76f5a9ba..f53a9d02fe 100644 --- a/admin-ui/plugins/services/__tests__/components/hooks/useCacheAudit.test.ts +++ b/admin-ui/plugins/services/__tests__/components/hooks/useCacheAudit.test.ts @@ -1,7 +1,7 @@ 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' +import { useCacheAudit } from 'Plugins/services/components/hooks/useCacheAudit' const mockLogAuditUserAction = jest.fn() const mockLoggerError = jest.fn() diff --git a/admin-ui/plugins/services/__tests__/helper/validations.test.ts b/admin-ui/plugins/services/__tests__/helper/validations.test.ts index f1f7fe1c9c..45bf3486b4 100644 --- a/admin-ui/plugins/services/__tests__/helper/validations.test.ts +++ b/admin-ui/plugins/services/__tests__/helper/validations.test.ts @@ -4,7 +4,7 @@ import { isRedisCache, isNativePersistenceCache, } from 'Plugins/services/helper/utils' -import type { CacheFormValues } from 'Plugins/services/Components/types' +import type { CacheFormValues } from 'Plugins/services/components/types' const baseCacheValues: CacheFormValues = { cacheProviderType: 'IN_MEMORY', diff --git a/admin-ui/plugins/services/Components/CacheInMemory.tsx b/admin-ui/plugins/services/components/CacheInMemory.tsx similarity index 100% rename from admin-ui/plugins/services/Components/CacheInMemory.tsx rename to admin-ui/plugins/services/components/CacheInMemory.tsx diff --git a/admin-ui/plugins/services/Components/CacheMemcached.tsx b/admin-ui/plugins/services/components/CacheMemcached.tsx similarity index 100% rename from admin-ui/plugins/services/Components/CacheMemcached.tsx rename to admin-ui/plugins/services/components/CacheMemcached.tsx diff --git a/admin-ui/plugins/services/Components/CacheNative.tsx b/admin-ui/plugins/services/components/CacheNative.tsx similarity index 100% rename from admin-ui/plugins/services/Components/CacheNative.tsx rename to admin-ui/plugins/services/components/CacheNative.tsx diff --git a/admin-ui/plugins/services/Components/CachePage.tsx b/admin-ui/plugins/services/components/CachePage.tsx similarity index 100% rename from admin-ui/plugins/services/Components/CachePage.tsx rename to admin-ui/plugins/services/components/CachePage.tsx diff --git a/admin-ui/plugins/services/Components/CacheRedis.tsx b/admin-ui/plugins/services/components/CacheRedis.tsx similarity index 100% rename from admin-ui/plugins/services/Components/CacheRedis.tsx rename to admin-ui/plugins/services/components/CacheRedis.tsx diff --git a/admin-ui/plugins/services/Components/PersistenceDetail.tsx b/admin-ui/plugins/services/components/PersistenceDetail.tsx similarity index 100% rename from admin-ui/plugins/services/Components/PersistenceDetail.tsx rename to admin-ui/plugins/services/components/PersistenceDetail.tsx diff --git a/admin-ui/plugins/services/Components/hooks/index.ts b/admin-ui/plugins/services/components/hooks/index.ts similarity index 100% rename from admin-ui/plugins/services/Components/hooks/index.ts rename to admin-ui/plugins/services/components/hooks/index.ts diff --git a/admin-ui/plugins/services/Components/hooks/useCacheAudit.ts b/admin-ui/plugins/services/components/hooks/useCacheAudit.ts similarity index 100% rename from admin-ui/plugins/services/Components/hooks/useCacheAudit.ts rename to admin-ui/plugins/services/components/hooks/useCacheAudit.ts diff --git a/admin-ui/plugins/services/Components/styles/CachePage.style.ts b/admin-ui/plugins/services/components/styles/CachePage.style.ts similarity index 100% rename from admin-ui/plugins/services/Components/styles/CachePage.style.ts rename to admin-ui/plugins/services/components/styles/CachePage.style.ts diff --git a/admin-ui/plugins/services/Components/styles/PersistenceDetail.style.ts b/admin-ui/plugins/services/components/styles/PersistenceDetail.style.ts similarity index 100% rename from admin-ui/plugins/services/Components/styles/PersistenceDetail.style.ts rename to admin-ui/plugins/services/components/styles/PersistenceDetail.style.ts diff --git a/admin-ui/plugins/services/Components/types.ts b/admin-ui/plugins/services/components/types.ts similarity index 100% rename from admin-ui/plugins/services/Components/types.ts rename to admin-ui/plugins/services/components/types.ts diff --git a/admin-ui/plugins/services/helper/constants.ts b/admin-ui/plugins/services/helper/constants.ts index eafbb761c5..96abdf21a7 100644 --- a/admin-ui/plugins/services/helper/constants.ts +++ b/admin-ui/plugins/services/helper/constants.ts @@ -1,4 +1,4 @@ -import type { CacheFormValues } from '../Components/types' +import type { CacheFormValues } from '../components/types' type CacheFieldLabel = { key: keyof CacheFormValues; label: string } diff --git a/admin-ui/plugins/services/helper/utils.ts b/admin-ui/plugins/services/helper/utils.ts index 5e5bf2a1b1..496155b7b3 100644 --- a/admin-ui/plugins/services/helper/utils.ts +++ b/admin-ui/plugins/services/helper/utils.ts @@ -8,7 +8,7 @@ import type { MemcachedCacheFormValues, RedisCacheFormValues, NativePersistenceCacheFormValues, -} from '../Components/types' +} from '../components/types' export const isInMemoryCache = ( values: CacheFormValues | CacheFormValuesUnion, diff --git a/admin-ui/plugins/services/plugin-metadata.ts b/admin-ui/plugins/services/plugin-metadata.ts index 1562fa3b3d..b413258ec7 100644 --- a/admin-ui/plugins/services/plugin-metadata.ts +++ b/admin-ui/plugins/services/plugin-metadata.ts @@ -3,8 +3,8 @@ import { CEDAR_ACTIONS } from '@/cedarling/constants' import { ROUTES } from '@/helpers/navigation' import { createLazyRoute } from '@/utils/RouteLoader' -const CachePage = createLazyRoute(() => import('./Components/CachePage')) -const PersistenceDetail = createLazyRoute(() => import('./Components/PersistenceDetail')) +const CachePage = createLazyRoute(() => import('./components/CachePage')) +const PersistenceDetail = createLazyRoute(() => import('./components/PersistenceDetail')) const pluginMetadata = { menus: [ diff --git a/admin-ui/plugins/user-management/components/__tests__/AvailableClaimsPanel.test.tsx b/admin-ui/plugins/user-management/components/__tests__/AvailableClaimsPanel.test.tsx new file mode 100644 index 0000000000..c21f952cee --- /dev/null +++ b/admin-ui/plugins/user-management/components/__tests__/AvailableClaimsPanel.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import i18n from '@/i18n' +import AvailableClaimsPanel from '../AvailableClaimsPanel' +import type { PersonAttribute, AvailableClaimsPanelProps } from '../../types' + +// Minimal builder for the rich JansAttribute shape: the panel filter only reads +// name, displayName and status, so a single typed cast keeps the fixtures readable. +const attr = (name: string, status = 'active', displayName?: string): PersonAttribute => + ({ name, displayName: displayName ?? name, status }) as PersonAttribute + +const setup = (overrides: Partial = {}) => { + const setSearchClaims = jest.fn() + const setSelectedClaimsToState = jest.fn() + const props: AvailableClaimsPanelProps = { + searchClaims: '', + setSearchClaims, + personAttributes: [attr('department'), attr('costCenter')], + selectedClaims: [], + setSelectedClaimsToState, + ...overrides, + } + render(, { wrapper: AppTestWrapper }) + return { setSearchClaims, setSelectedClaimsToState } +} + +describe('AvailableClaimsPanel', () => { + it('shows no options until a search term is entered', () => { + setup({ searchClaims: '' }) + expect(screen.queryByRole('button', { name: /department/i })).not.toBeInTheDocument() + }) + + it('lists matching active claims for a search term', () => { + setup({ searchClaims: 'department' }) + expect(screen.getByRole('button', { name: /department/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /costCenter/i })).not.toBeInTheDocument() + }) + + it('excludes inactive claims from the results', () => { + setup({ searchClaims: 'department', personAttributes: [attr('department', 'inactive')] }) + expect(screen.queryByRole('button', { name: /department/i })).not.toBeInTheDocument() + }) + + it('excludes reserved standard claims even when they match', () => { + setup({ searchClaims: 'uid', personAttributes: [attr('uid')] }) + expect(screen.queryByRole('button', { name: /^uid$/i })).not.toBeInTheDocument() + }) + + it('excludes claims that are already selected', () => { + setup({ + searchClaims: 'department', + personAttributes: [attr('department')], + selectedClaims: [attr('department')], + }) + expect(screen.queryByRole('button', { name: /department/i })).not.toBeInTheDocument() + }) + + it('reports search input changes through setSearchClaims', () => { + const { setSearchClaims } = setup() + fireEvent.change(screen.getByPlaceholderText(i18n.t('placeholders.search_claims_here')), { + target: { value: 'dep' }, + }) + expect(setSearchClaims).toHaveBeenCalledWith('dep') + }) + + it('adds a claim to state when its option is clicked', () => { + const { setSelectedClaimsToState } = setup({ searchClaims: 'department' }) + fireEvent.click(screen.getByRole('button', { name: /department/i })) + expect(setSelectedClaimsToState).toHaveBeenCalledWith( + expect.objectContaining({ name: 'department' }), + ) + }) + + it('clears the search via the clear button', () => { + const { setSearchClaims } = setup({ searchClaims: 'dep' }) + fireEvent.click(screen.getByRole('button', { name: i18n.t('actions.clear_search') })) + expect(setSearchClaims).toHaveBeenCalledWith('') + }) +}) diff --git a/admin-ui/plugins/user-management/components/__tests__/UserClaimEntry.test.tsx b/admin-ui/plugins/user-management/components/__tests__/UserClaimEntry.test.tsx new file mode 100644 index 0000000000..c82fecf5ce --- /dev/null +++ b/admin-ui/plugins/user-management/components/__tests__/UserClaimEntry.test.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import type { FormikProps } from 'formik' +import UserClaimEntry from '../UserClaimEntry' +import { COUNTRY_ATTR, BIRTHDATE_ATTR } from '../../common' +import { JANS_ADMIN_UI_ROLE_ATTR } from '@/constants' +import type { PersonAttribute } from '../../types' +import type { UserFormValues } from '../../types/CommonTypes' + +// The child rows have their own suites; stub each to a marker that echoes the +// props this component computes (branch selection, value coercion, options). +const mockRolesResult = { + data: [{ role: 'api-admin' }, { role: 'api-viewer' }, { role: undefined }], + isLoading: false, + isError: false, +} +jest.mock('JansConfigApi', () => ({ + useGetAllAdminuiRoles: jest.fn(() => mockRolesResult), +})) + +jest.mock('@/context/theme/themeContext', () => ({ + useTheme: () => ({ state: { theme: 'light' } }), +})) +jest.mock('@/context/theme/config', () => ({ __esModule: true, default: () => ({}) })) +jest.mock('../UserClaimEntry.style', () => ({ + useStyles: () => ({ + classes: { claimRow: 'claimRow', claimCard: 'claimCard', removeButton: 'removeButton' }, + }), +})) + +type FieldValue = string | number | boolean | Array +type AutocompleteProps = { + name: string + options: string[] + value: string[] + disabled?: boolean + allowCustom?: boolean + onChange: (next: string[]) => void +} +type SelectRowProps = { + name: string + value: FieldValue + values?: Array<{ key: string; value: string }> +} +type InputRowProps = { + name: string + type: string + value: FieldValue + isBoolean?: boolean + handler: () => void +} + +jest.mock('Routes/Apps/Gluu/GluuAutocomplete', () => ({ + __esModule: true, + default: ({ name, options, value, disabled, allowCustom, onChange }: AutocompleteProps) => ( +
+ {JSON.stringify(options)} + {JSON.stringify(value)} + +
+ ), +})) +jest.mock('Routes/Apps/Gluu/GluuRemovableSelectRow', () => ({ + __esModule: true, + default: ({ name, value, values }: SelectRowProps) => ( +
+ ), +})) +jest.mock('Routes/Apps/Gluu/GluuRemovableInputRow', () => ({ + __esModule: true, + default: ({ name, type, value, isBoolean, handler }: InputRowProps) => ( +
+ +
+ ), +})) +jest.mock('@/components/icons', () => ({ Close: () => x })) +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }) })) + +// The builder loosens dataType to a plain string so tests can pass raw casing +// like 'DATE' (the component lowercases it at runtime); the result is cast back. +const attr = ( + over: Partial> & { dataType?: string }, +): PersonAttribute => + ({ name: 'department', displayName: 'Department', ...over }) as PersonAttribute + +const formikWith = (values: Record) => { + const setFieldValue = jest.fn() + const setFieldTouched = jest.fn() + const formik = { + values, + setFieldValue, + setFieldTouched, + } as object as FormikProps + return { formik, setFieldValue, setFieldTouched } +} + +const renderEntry = (data: PersonAttribute, values: Record = {}) => { + const { formik, setFieldValue, setFieldTouched } = formikWith(values) + const handler = jest.fn() + render() + return { handler, setFieldValue, setFieldTouched } +} + +describe('UserClaimEntry', () => { + describe('multi-valued attribute branch', () => { + it('renders an autocomplete listing the current string values', () => { + renderEntry(attr({ oxMultiValuedAttribute: true }), { department: ['a', 'b', 3] }) + expect(screen.getByTestId('autocomplete')).toBeInTheDocument() + // Non-string entries are filtered out of the value list. + expect(screen.getByTestId('ac-value')).toHaveTextContent(JSON.stringify(['a', 'b'])) + }) + + it('uses fetched roles as options and blocks custom entries for the role attribute', () => { + renderEntry(attr({ name: JANS_ADMIN_UI_ROLE_ATTR, oxMultiValuedAttribute: true }), {}) + // undefined role is dropped, only truthy roles remain. + expect(screen.getByTestId('ac-options')).toHaveTextContent( + JSON.stringify(['api-admin', 'api-viewer']), + ) + expect(screen.getByTestId('autocomplete')).toHaveAttribute('data-allowcustom', 'false') + }) + + it('pushes selection changes and marks the field touched', () => { + const { setFieldValue, setFieldTouched } = renderEntry( + attr({ oxMultiValuedAttribute: true }), + {}, + ) + fireEvent.click(screen.getByText('change-ac')) + expect(setFieldValue).toHaveBeenCalledWith('department', ['x']) + expect(setFieldTouched).toHaveBeenCalledWith('department', true, false) + }) + + it('removes the claim via the remove button', () => { + const { handler } = renderEntry(attr({ oxMultiValuedAttribute: true }), {}) + fireEvent.click(screen.getByLabelText('actions.remove')) + expect(handler).toHaveBeenCalledWith('department') + }) + }) + + describe('country branch', () => { + it('renders a select row of countries for the country attribute', () => { + renderEntry(attr({ name: COUNTRY_ATTR }), { [COUNTRY_ATTR]: 'US' }) + const row = screen.getByTestId('select-row') + expect(row).toHaveAttribute('data-name', COUNTRY_ATTR) + expect(row).toHaveAttribute('data-value', 'US') + expect(Number(row.getAttribute('data-count'))).toBeGreaterThan(0) + }) + }) + + describe('input row branch', () => { + it('renders a date input for the birthdate attribute', () => { + renderEntry(attr({ name: BIRTHDATE_ATTR }), { [BIRTHDATE_ATTR]: '2000-01-01' }) + expect(screen.getByTestId('input-row')).toHaveAttribute('data-type', 'date') + }) + + it('renders a date input when the dataType is date', () => { + renderEntry(attr({ name: 'anniversary', dataType: 'DATE' }), {}) + expect(screen.getByTestId('input-row')).toHaveAttribute('data-type', 'date') + }) + + it('renders a boolean input coercing the value to a boolean', () => { + renderEntry(attr({ name: 'active', dataType: 'boolean' }), { active: 1 }) + const row = screen.getByTestId('input-row') + expect(row).toHaveAttribute('data-boolean', 'true') + expect(row).toHaveAttribute('data-value', 'true') + }) + + it('renders a plain text input for a generic string attribute', () => { + renderEntry(attr({ name: 'nickname', dataType: 'string' }), { nickname: 'ace' }) + const row = screen.getByTestId('input-row') + expect(row).toHaveAttribute('data-type', 'text') + expect(row).toHaveAttribute('data-value', 'ace') + }) + + it('removes the claim via the remove handler', () => { + const { handler } = renderEntry(attr({ name: 'nickname' }), {}) + fireEvent.click(screen.getByText('remove')) + expect(handler).toHaveBeenCalledWith('nickname') + }) + }) +}) From 3bc12555f0f0da156f909475707da6cc54c588c5 Mon Sep 17 00:00:00 2001 From: faisalsiddique4400 Date: Thu, 2 Jul 2026 18:32:44 +0500 Subject: [PATCH 2/3] feat(admin-ui): add test cases for all remaining uncovered files (#2915) Signed-off-by: faisalsiddique4400 --- .../components/App/__tests__/AppMain.test.tsx | 80 +++++++++ .../Layout/__tests__/Layout.test.tsx | 92 +++++++++++ .../Layout/__tests__/LayoutContent.test.tsx | 53 ++++++ .../Layout/__tests__/LayoutSidebar.test.tsx | 36 ++++ .../Layout/__tests__/withPageConfig.test.tsx | 47 ++++++ .../Theme/__tests__/ThemeClass.test.tsx | 34 ++++ .../Theme/__tests__/ThemeProvider.test.tsx | 63 +++++++ .../Dashboards/Chart/__tests__/utils.test.ts | 31 ++++ .../app/utils/__tests__/ApiResources.test.ts | 32 ++++ .../orval/__tests__/axiosInstance.test.ts | 152 +++++++++++++++++ .../utils/__tests__/validations.test.ts | 69 ++++++++ .../Acrs/helper/__tests__/validations.test.ts | 130 +++++++++++++++ .../AgamaFlows/__tests__/helper/utils.test.ts | 118 +++++++++++++ .../utils/__tests__/validations.test.ts | 73 +++++++++ .../Logging/__tests__/utils.test.ts | 86 ++++++++++ .../Logging/__tests__/validations.test.ts | 65 ++++++++ .../Ssa/__tests__/helper/utils.test.ts | 117 +++++++++++++ .../Ssa/__tests__/helper/validations.test.ts | 92 +++++++++++ .../__tests__/formValidationUtils.test.ts | 64 ++++++++ .../fido/__tests__/helper/utils.test.ts | 143 ++++++++++++++++ admin-ui/plugins/saml/__tests__/utils.test.ts | 155 ++++++++++++++++++ .../saml/__tests__/validations.test.ts | 106 ++++++++++++ .../components/helper/__tests__/utils.test.ts | 125 ++++++++++++++ .../helper/__tests__/validations.test.ts | 105 ++++++++++++ .../__tests__/helper/utils.test.ts | 129 +++++++++++++++ 25 files changed, 2197 insertions(+) create mode 100644 admin-ui/app/components/App/__tests__/AppMain.test.tsx create mode 100644 admin-ui/app/components/Layout/__tests__/Layout.test.tsx create mode 100644 admin-ui/app/components/Layout/__tests__/LayoutContent.test.tsx create mode 100644 admin-ui/app/components/Layout/__tests__/LayoutSidebar.test.tsx create mode 100644 admin-ui/app/components/Layout/__tests__/withPageConfig.test.tsx create mode 100644 admin-ui/app/components/Theme/__tests__/ThemeClass.test.tsx create mode 100644 admin-ui/app/components/Theme/__tests__/ThemeProvider.test.tsx create mode 100644 admin-ui/app/routes/Dashboards/Chart/__tests__/utils.test.ts create mode 100644 admin-ui/app/utils/__tests__/ApiResources.test.ts create mode 100644 admin-ui/orval/__tests__/axiosInstance.test.ts create mode 100644 admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/__tests__/validations.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Authentication/Acrs/helper/__tests__/validations.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Authentication/AgamaFlows/__tests__/helper/utils.test.ts create mode 100644 admin-ui/plugins/auth-server/components/ConfigApiProperties/utils/__tests__/validations.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Logging/__tests__/utils.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Logging/__tests__/validations.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Ssa/__tests__/helper/utils.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Ssa/__tests__/helper/validations.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Ssa/utils/__tests__/formValidationUtils.test.ts create mode 100644 admin-ui/plugins/fido/__tests__/helper/utils.test.ts create mode 100644 admin-ui/plugins/saml/__tests__/utils.test.ts create mode 100644 admin-ui/plugins/saml/__tests__/validations.test.ts create mode 100644 admin-ui/plugins/scripts/components/helper/__tests__/utils.test.ts create mode 100644 admin-ui/plugins/scripts/components/helper/__tests__/validations.test.ts create mode 100644 admin-ui/plugins/user-management/__tests__/helper/utils.test.ts diff --git a/admin-ui/app/components/App/__tests__/AppMain.test.tsx b/admin-ui/app/components/App/__tests__/AppMain.test.tsx new file mode 100644 index 0000000000..7448d08ca0 --- /dev/null +++ b/admin-ui/app/components/App/__tests__/AppMain.test.tsx @@ -0,0 +1,80 @@ +import type { PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' + +// Router and the route selector are covered by their own suites; stub them so +// this test targets AppMain's composition and error wiring. +jest.mock('react-router-dom', () => ({ + BrowserRouter: ({ children }: PropsWithChildren<{ basename?: string }>) => ( +
{children}
+ ), +})) + +jest.mock('../AuthenticatedRouteSelector', () => ({ + __esModule: true, + default: () =>
routes
, +})) + +jest.mock('Routes/Apps/Gluu/GluuErrorScreen', () => ({ + __esModule: true, + default: () =>
error
, +})) + +type ErrorBoundaryProps = PropsWithChildren<{ + FallbackComponent: React.ComponentType + // Loosely typed so a test can pass a non-Error value through the onError path. + onError: (error: Error | string, info: { componentStack: string }) => void +}> + +// Capture the ErrorBoundary props so we can assert AppMain's onError forwarding. +let capturedProps: ErrorBoundaryProps | null = null +jest.mock('react-error-boundary', () => ({ + ErrorBoundary: (props: ErrorBoundaryProps) => { + capturedProps = props + return
{props.children}
+ }, +})) + +const mockLogUiCrash = jest.fn() +jest.mock('@/utils/logUiCrash', () => ({ + __esModule: true, + default: (error: Error, stack?: string | null) => mockLogUiCrash(error, stack), +})) + +import AppMain from '../AppMain' + +beforeEach(() => { + capturedProps = null + mockLogUiCrash.mockReset() +}) + +describe('AppMain', () => { + it('nests the route selector inside the router and error boundary', () => { + render() + expect(screen.getByTestId('router')).toBeInTheDocument() + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + expect(screen.getByTestId('route-selector')).toBeInTheDocument() + }) + + it('wires GluuErrorScreen as the fallback component', () => { + render() + const Fallback = capturedProps?.FallbackComponent as React.ComponentType + render() + expect(screen.getByTestId('error-screen')).toBeInTheDocument() + }) + + it('logs a thrown Error through logUiCrash with the component stack', () => { + render() + const err = new Error('boom') + capturedProps?.onError(err, { componentStack: 'at ' }) + expect(mockLogUiCrash).toHaveBeenCalledWith(err, 'at ') + }) + + it('coerces a non-Error value into an Error before logging', () => { + render() + capturedProps?.onError('string failure', { componentStack: 'at ' }) + const [loggedError, stack] = mockLogUiCrash.mock.calls[0] + expect(loggedError).toBeInstanceOf(Error) + expect((loggedError as Error).message).toBe('string failure') + expect(stack).toBe('at ') + }) +}) diff --git a/admin-ui/app/components/Layout/__tests__/Layout.test.tsx b/admin-ui/app/components/Layout/__tests__/Layout.test.tsx new file mode 100644 index 0000000000..99bbfb6e41 --- /dev/null +++ b/admin-ui/app/components/Layout/__tests__/Layout.test.tsx @@ -0,0 +1,92 @@ +import type { PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { Layout } from '../Layout' +import { LayoutSidebar } from '../LayoutSidebar' +import { LayoutNavbar } from '../LayoutNavbar' +import { LayoutContent } from '../LayoutContent' + +// ThemeClass just yields a class string; stub it so Layout renders without a +// full theme context and we can assert the composition directly. +jest.mock('../../Theme', () => ({ + ThemeClass: ({ children }: { children: (cls: string) => React.ReactNode }) => + children('layout--theme--light--primary'), +})) + +// SetTitle touches document.title; stub it to a no-op hook. +jest.mock('Utils/SetTitle', () => ({ __esModule: true, default: () => {} })) + +// The real content subtree needs theme wiring of its own; replace it with a +// marker that still carries the content layoutPartName. +jest.mock('../LayoutContent', () => { + const Content = ({ children }: PropsWithChildren) =>
{children}
+ Content.layoutPartName = 'content' + return { LayoutContent: Content } +}) + +beforeAll(() => { + window.matchMedia = + window.matchMedia || + ((query: string) => + ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as object as MediaQueryList) +}) + +const renderLayout = (children: React.ReactNode) => + render({{children}}) + +describe('Layout', () => { + it('renders the outer layout container', () => { + const { container } = renderLayout( + +
page
+
, + ) + expect(container.querySelector('.layout')).toBeInTheDocument() + }) + + it('places sidebar, navbar and content into the layout', () => { + renderLayout( + <> + +
nav
+
+ +
top
+
+ +
body
+
+ , + ) + expect(screen.getByTestId('side')).toBeInTheDocument() + expect(screen.getByTestId('bar')).toBeInTheDocument() + expect(screen.getByTestId('content')).toBeInTheDocument() + }) + + it('renders content even when no sidebar is provided', () => { + renderLayout( + +
body
+
, + ) + expect(screen.getByTestId('content')).toBeInTheDocument() + expect(screen.queryByTestId('side')).not.toBeInTheDocument() + }) + + it('passes through children that are not recognised layout parts', () => { + renderLayout( + <> + +
body
+
+
floating
+ , + ) + expect(screen.getByTestId('extra')).toBeInTheDocument() + }) +}) diff --git a/admin-ui/app/components/Layout/__tests__/LayoutContent.test.tsx b/admin-ui/app/components/Layout/__tests__/LayoutContent.test.tsx new file mode 100644 index 0000000000..ba07983744 --- /dev/null +++ b/admin-ui/app/components/Layout/__tests__/LayoutContent.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react' +import { LayoutContent } from '../LayoutContent' +import { ThemeContext } from '@/context/theme/themeContext' +import { THEME_LIGHT, THEME_DARK } from '@/context/theme/constants' +import type { ThemeContextType } from '@/context/theme/themeContext' + +const themeValue = (theme: typeof THEME_LIGHT | typeof THEME_DARK): ThemeContextType => ({ + state: { theme }, + dispatch: jest.fn(), +}) + +const renderWithTheme = (theme: typeof THEME_LIGHT | typeof THEME_DARK) => + render( + + +
page
+
+
, + ) + +describe('LayoutContent', () => { + it('exposes the content layout part name', () => { + expect(LayoutContent.layoutPartName).toBe('content') + }) + + it('renders its children inside the content container', () => { + const { container } = renderWithTheme(THEME_LIGHT) + expect(container.querySelector('.layout__content')).toBeInTheDocument() + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('applies theme-derived css variables to the document element', () => { + renderWithTheme(THEME_LIGHT) + expect(document.documentElement.style.getPropertyValue('--theme-scrollbar-width')).not.toBe('') + }) + + it('renders for the dark theme without throwing', () => { + expect(() => renderWithTheme(THEME_DARK)).not.toThrow() + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('throws when rendered outside a ThemeProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + expect(() => + render( + +
x
+
, + ), + ).toThrow('ThemeContext must be used within a ThemeProvider') + jest.restoreAllMocks() + }) +}) diff --git a/admin-ui/app/components/Layout/__tests__/LayoutSidebar.test.tsx b/admin-ui/app/components/Layout/__tests__/LayoutSidebar.test.tsx new file mode 100644 index 0000000000..fd785df46e --- /dev/null +++ b/admin-ui/app/components/Layout/__tests__/LayoutSidebar.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react' +import { LayoutSidebar } from '../LayoutSidebar' + +describe('LayoutSidebar', () => { + it('exposes the sidebar layout part name', () => { + expect(LayoutSidebar.layoutPartName).toBe('sidebar') + }) + + it('wraps its child in the sidebar layout container', () => { + const { container } = render( + +
nav
+
, + ) + const wrapper = container.querySelector('.layout__sidebar') + expect(wrapper).toBeInTheDocument() + expect(wrapper?.querySelector('[data-testid="content"]')).toBeInTheDocument() + }) + + it('adds the slim modifier when sidebarSlim is set', () => { + const { container } = render(x) + expect(container.querySelector('.layout__sidebar')).toHaveClass('layout__sidebar--slim') + }) + + it('adds the collapsed modifier when sidebarCollapsed is set', () => { + const { container } = render(x) + expect(container.querySelector('.layout__sidebar')).toHaveClass('layout__sidebar--collapsed') + }) + + it('omits both modifiers by default', () => { + const { container } = render(x) + const wrapper = container.querySelector('.layout__sidebar') + expect(wrapper).not.toHaveClass('layout__sidebar--slim') + expect(wrapper).not.toHaveClass('layout__sidebar--collapsed') + }) +}) diff --git a/admin-ui/app/components/Layout/__tests__/withPageConfig.test.tsx b/admin-ui/app/components/Layout/__tests__/withPageConfig.test.tsx new file mode 100644 index 0000000000..26c84afdac --- /dev/null +++ b/admin-ui/app/components/Layout/__tests__/withPageConfig.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react' +import { withPageConfig } from '../withPageConfig' +import { PageConfigContext } from '../PageConfigContext' +import type { PageConfig } from '../types' + +type ProbeProps = { pageConfig?: PageConfig | null; label: string } + +// Echoes the injected pageConfig so we can assert what the HOC provided. +const Probe = ({ pageConfig, label }: ProbeProps) => ( +
+ {label} +
+) + +describe('withPageConfig', () => { + it('injects the context pageConfig into the wrapped component', () => { + const Wrapped = withPageConfig(Probe) + const config: PageConfig = { + sidebarCollapsed: true, + screenSize: 'lg', + toggleSidebar: () => {}, + } + render( + + + , + ) + expect(screen.getByTestId('probe')).toHaveAttribute('data-collapsed', 'true') + }) + + it('forwards the remaining props through to the wrapped component', () => { + const Wrapped = withPageConfig(Probe) + render( + {} }}> + + , + ) + expect(screen.getByTestId('probe')).toHaveTextContent('forwarded') + }) + + it('uses the default context config when no provider is present', () => { + const Wrapped = withPageConfig(Probe) + render() + // The default PageConfigContext has sidebarCollapsed: false. + expect(screen.getByTestId('probe')).toHaveAttribute('data-collapsed', 'false') + }) +}) diff --git a/admin-ui/app/components/Theme/__tests__/ThemeClass.test.tsx b/admin-ui/app/components/Theme/__tests__/ThemeClass.test.tsx new file mode 100644 index 0000000000..3dd4d9c5a3 --- /dev/null +++ b/admin-ui/app/components/Theme/__tests__/ThemeClass.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react' +import { ThemeClass } from '../ThemeClass' +import { ThemeContext } from '../ThemeContext' +import type { ThemeContextState } from '../types' + +const renderWithTheme = (state: ThemeContextState | null) => + render( + + {(themeClass) => {themeClass}} + , + ) + +const themeState = (style: string, color: string): ThemeContextState => ({ + style, + color, + onChangeTheme: () => {}, +}) + +describe('ThemeClass', () => { + it('builds the theme class from the context style and color', () => { + renderWithTheme(themeState('dark', 'primary')) + expect(screen.getByTestId('cls')).toHaveTextContent('layout--theme--dark--primary') + }) + + it('reflects a different style/color pairing', () => { + renderWithTheme(themeState('light', 'accent')) + expect(screen.getByTestId('cls')).toHaveTextContent('layout--theme--light--accent') + }) + + it('renders nothing when there is no theme context', () => { + const { container } = renderWithTheme(null) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/admin-ui/app/components/Theme/__tests__/ThemeProvider.test.tsx b/admin-ui/app/components/Theme/__tests__/ThemeProvider.test.tsx new file mode 100644 index 0000000000..f348e51457 --- /dev/null +++ b/admin-ui/app/components/Theme/__tests__/ThemeProvider.test.tsx @@ -0,0 +1,63 @@ +import React, { use } from 'react' +import { render, screen, act } from '@testing-library/react' +import { ThemeProvider } from '../ThemeProvider' +import { ThemeContext } from '../ThemeContext' +import { THEME_LIGHT } from '@/context/theme/constants' + +// Reads the provided theme context and exposes a control to flip the color. +const Probe = () => { + const theme = use(ThemeContext) + if (!theme) return null + return ( +
+ {theme.style} + {theme.color} + +
+ ) +} + +describe('ThemeProvider', () => { + it('defaults to the light style and primary color', () => { + render( + + + , + ) + expect(screen.getByTestId('style')).toHaveTextContent(THEME_LIGHT) + expect(screen.getByTestId('color')).toHaveTextContent('primary') + }) + + it('applies the initial style and color on mount', () => { + render( + + + , + ) + expect(screen.getByTestId('style')).toHaveTextContent('dark') + expect(screen.getByTestId('color')).toHaveTextContent('accent') + }) + + it('updates the color via onChangeTheme', () => { + render( + + + , + ) + act(() => { + screen.getByRole('button', { name: 'change' }).click() + }) + expect(screen.getByTestId('color')).toHaveTextContent('accent') + }) + + it('renders its children', () => { + render( + +
content
+
, + ) + expect(screen.getByTestId('child')).toBeInTheDocument() + }) +}) diff --git a/admin-ui/app/routes/Dashboards/Chart/__tests__/utils.test.ts b/admin-ui/app/routes/Dashboards/Chart/__tests__/utils.test.ts new file mode 100644 index 0000000000..13b9bf5fc8 --- /dev/null +++ b/admin-ui/app/routes/Dashboards/Chart/__tests__/utils.test.ts @@ -0,0 +1,31 @@ +import { formatTooltipValue } from 'Routes/Dashboards/Chart/utils' + +describe('formatTooltipValue', () => { + it('returns an empty string for null', () => { + expect(formatTooltipValue(null)).toBe('') + }) + + it('returns an empty string for undefined', () => { + expect(formatTooltipValue(undefined as never)).toBe('') + }) + + it('stringifies a string as-is', () => { + expect(formatTooltipValue('hello')).toBe('hello') + }) + + it('stringifies a number', () => { + expect(formatTooltipValue(42)).toBe('42') + }) + + it('stringifies a boolean', () => { + expect(formatTooltipValue(true)).toBe('true') + }) + + it('JSON-stringifies an array', () => { + expect(formatTooltipValue([1, 2])).toBe('[1,2]') + }) + + it('JSON-stringifies an object', () => { + expect(formatTooltipValue({ a: 1 })).toBe('{"a":1}') + }) +}) diff --git a/admin-ui/app/utils/__tests__/ApiResources.test.ts b/admin-ui/app/utils/__tests__/ApiResources.test.ts new file mode 100644 index 0000000000..b2d4e3c815 --- /dev/null +++ b/admin-ui/app/utils/__tests__/ApiResources.test.ts @@ -0,0 +1,32 @@ +import * as ApiResources from '@/utils/ApiResources' + +describe('ApiResources', () => { + it('exposes the expected resource-name string constants', () => { + expect(ApiResources.SCOPE).toBe('scope') + expect(ApiResources.ATTRIBUTE).toBe('attribute') + expect(ApiResources.SCRIPT).toBe('script') + expect(ApiResources.JSON_CONFIG).toBe('json_properties') + expect(ApiResources.SETTINGS).toBe('settings') + expect(ApiResources.CACHE).toBe('cache') + expect(ApiResources.AUTHN).toBe('authn') + expect(ApiResources.SSA).toBe('ssa') + expect(ApiResources.WEBHOOK).toBe('webhook') + expect(ApiResources.ASSET).toBe('asset') + }) + + it('keeps every resource name unique', () => { + const values = [ + ApiResources.SCOPE, + ApiResources.ATTRIBUTE, + ApiResources.SCRIPT, + ApiResources.JSON_CONFIG, + ApiResources.SETTINGS, + ApiResources.CACHE, + ApiResources.AUTHN, + ApiResources.SSA, + ApiResources.WEBHOOK, + ApiResources.ASSET, + ] + expect(new Set(values).size).toBe(values.length) + }) +}) diff --git a/admin-ui/orval/__tests__/axiosInstance.test.ts b/admin-ui/orval/__tests__/axiosInstance.test.ts new file mode 100644 index 0000000000..4b9bfbf77d --- /dev/null +++ b/admin-ui/orval/__tests__/axiosInstance.test.ts @@ -0,0 +1,152 @@ +import type { AxiosRequestConfig } from 'axios' +import type { CancellablePromise } from '../types' + +// axiosInstance resolves its baseURL at module load and builds the shared +// AXIOS_INSTANCE via Axios.create, so each scenario sets the relevant globals, +// resets the module registry, then re-imports to observe the created config. +type ResponsePayload = { ok: boolean } +type AxiosConfig = { baseURL: string; timeout: number } +type CreatedInstance = ((config: AxiosRequestConfig) => Promise<{ data: ResponsePayload }>) & { + __config: AxiosConfig + defaults: { headers: { common: Record } } +} + +const instanceCalls: AxiosRequestConfig[] = [] +const cancelCalls: string[] = [] + +// A fresh mock instance is produced per Axios.create call; it records the request +// config it was invoked with and resolves to a fixed payload. +const makeInstance = (config: AxiosConfig): CreatedInstance => { + const inst = ((requestConfig: AxiosRequestConfig) => { + instanceCalls.push(requestConfig) + return Promise.resolve({ data: { ok: true } }) + }) as CreatedInstance + inst.__config = config + inst.defaults = { headers: { common: {} } } + return inst +} + +const mockCreate = jest.fn((config: AxiosConfig) => makeInstance(config)) +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: (c: AxiosConfig) => mockCreate(c), + CancelToken: { + source: () => ({ + token: 'cancel-token', + cancel: (msg: string) => cancelCalls.push(msg), + }), + }, + }, +})) + +type AxiosModule = typeof import('../axiosInstance') + +const loadModule = async (): Promise => { + let mod!: AxiosModule + await jest.isolateModulesAsync(async () => { + mod = await import('../axiosInstance') + }) + return mod +} + +const lastCreateConfig = (): AxiosConfig => + mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] + +const ORIGINAL_ENV = process.env.CONFIG_API_BASE_URL + +beforeEach(() => { + mockCreate.mockClear() + instanceCalls.length = 0 + cancelCalls.length = 0 + delete (window as { configApiBaseUrl?: string }).configApiBaseUrl + delete process.env.CONFIG_API_BASE_URL +}) + +afterAll(() => { + if (ORIGINAL_ENV === undefined) delete process.env.CONFIG_API_BASE_URL + else process.env.CONFIG_API_BASE_URL = ORIGINAL_ENV +}) + +describe('AXIOS_INSTANCE base url resolution', () => { + it('prefers a valid window.configApiBaseUrl', async () => { + ;(window as { configApiBaseUrl?: string }).configApiBaseUrl = 'https://runtime.example.com' + process.env.CONFIG_API_BASE_URL = 'https://env.example.com' + await loadModule() + expect(lastCreateConfig().baseURL).toBe('https://runtime.example.com') + }) + + it('ignores an unresolved python-placeholder window url and falls back to env', async () => { + ;(window as { configApiBaseUrl?: string }).configApiBaseUrl = '%(config_api_base_url)s' + process.env.CONFIG_API_BASE_URL = 'https://env.example.com' + await loadModule() + expect(lastCreateConfig().baseURL).toBe('https://env.example.com') + }) + + it('falls back to the env url when no window url is present', async () => { + process.env.CONFIG_API_BASE_URL = 'https://env.example.com' + await loadModule() + expect(lastCreateConfig().baseURL).toBe('https://env.example.com') + }) + + it('defaults to an empty base url when neither is set', async () => { + await loadModule() + expect(lastCreateConfig().baseURL).toBe('') + }) + + it('configures a 60s timeout', async () => { + await loadModule() + expect(lastCreateConfig().timeout).toBe(60000) + }) +}) + +describe('setApiToken', () => { + it('sets a bearer Authorization header when given a token', async () => { + const mod = await loadModule() + mod.setApiToken('abc123') + expect(mockCreate.mock.results[0].value.defaults.headers.common['Authorization']).toBe( + 'Bearer abc123', + ) + }) + + it('removes the Authorization header when given null', async () => { + const mod = await loadModule() + mod.setApiToken('abc123') + mod.setApiToken(null) + expect( + mockCreate.mock.results[0].value.defaults.headers.common['Authorization'], + ).toBeUndefined() + }) +}) + +describe('customInstance', () => { + it('forwards the request through the axios instance and unwraps data', async () => { + const mod = await loadModule() + const result = await mod.customInstance({ url: '/x', method: 'GET' }) + expect(result).toEqual({ ok: true }) + expect(instanceCalls[0]).toMatchObject({ + url: '/x', + method: 'GET', + cancelToken: 'cancel-token', + }) + }) + + it('passes an abort signal through to the request', async () => { + const mod = await loadModule() + const controller = new AbortController() + await mod.customInstance({ url: '/y' }, { signal: controller.signal }) + expect(instanceCalls[0].signal).toBe(controller.signal) + }) + + it('exposes a cancel() that cancels the underlying token source', async () => { + const mod = await loadModule() + // The public return type is Promise; the implementation augments it with + // cancel(), so narrow to CancellablePromise to reach it type-safely. + const promise = mod.customInstance({ + url: '/z', + }) as CancellablePromise + promise.cancel?.() + await promise + expect(cancelCalls[0]).toBe('Operation canceled by the user.') + }) +}) diff --git a/admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/__tests__/validations.test.ts b/admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/__tests__/validations.test.ts new file mode 100644 index 0000000000..6f7bc19993 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/AuthServerProperties/Properties/utils/__tests__/validations.test.ts @@ -0,0 +1,69 @@ +import type { TFunction } from 'i18next' +import { createAppConfigurationSchema } from 'Plugins/auth-server/components/AuthServerProperties/Properties/utils/validations' + +const t = ((key: string) => key) as TFunction + +describe('createAppConfigurationSchema', () => { + it('accepts an empty object (all fields nullable/optional)', async () => { + await expect(createAppConfigurationSchema().isValid({})).resolves.toBe(true) + }) + + it('accepts valid urls and non-negative numbers', async () => { + await expect( + createAppConfigurationSchema().isValid({ + issuer: 'https://issuer.example.com', + tokenEndpoint: 'https://issuer.example.com/token', + accessTokenLifetime: 3600, + idTokenLifetime: 0, + }), + ).resolves.toBe(true) + }) + + it('rejects a malformed url with the default message', async () => { + await expect(createAppConfigurationSchema().validate({ issuer: 'not-a-url' })).rejects.toThrow( + 'Invalid URL format', + ) + }) + + it('uses the translated url message when a t function is supplied', async () => { + await expect(createAppConfigurationSchema(t).validate({ issuer: 'not-a-url' })).rejects.toThrow( + 'validation_messages.invalid_url_format', + ) + }) + + it('rejects a negative duration with the default message', async () => { + await expect( + createAppConfigurationSchema().validate({ accessTokenLifetime: -1 }), + ).rejects.toThrow('Must be non-negative') + }) + + it('uses the translated non-negative message when a t function is supplied', async () => { + await expect( + createAppConfigurationSchema(t).validate({ accessTokenLifetime: -1 }), + ).rejects.toThrow('validation_messages.must_be_non_negative') + }) + + it('coerces an empty-string number field to null (treated as absent)', async () => { + // The number fields carry a transform mapping '' -> null; feed the raw form + // string through a loosely-typed input to exercise it. + const input: Record = { accessTokenLifetime: '' } + await expect(createAppConfigurationSchema().isValid(input)).resolves.toBe(true) + }) + + it('validates the nested ssaConfiguration object', async () => { + await expect( + createAppConfigurationSchema().validate({ + ssaConfiguration: { ssaEndpoint: 'not-a-url' }, + }), + ).rejects.toThrow('Invalid URL format') + + await expect( + createAppConfigurationSchema().isValid({ + ssaConfiguration: { + ssaEndpoint: 'https://issuer.example.com/ssa', + ssaExpirationInDays: 30, + }, + }), + ).resolves.toBe(true) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Authentication/Acrs/helper/__tests__/validations.test.ts b/admin-ui/plugins/auth-server/components/Authentication/Acrs/helper/__tests__/validations.test.ts new file mode 100644 index 0000000000..00751c3428 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Authentication/Acrs/helper/__tests__/validations.test.ts @@ -0,0 +1,130 @@ +import { getAuthNValidationSchema } from 'Plugins/auth-server/components/Authentication/Acrs/helper/validations' +import { AUTH_METHOD_NAMES } from 'Plugins/auth-server/components/Authentication/constants' +import type { AuthNItem } from 'Plugins/auth-server/components/Authentication/types' + +const baseValid = { + acr: 'my-acr', + level: 1, + defaultAuthNMethod: false, +} + +describe('getAuthNValidationSchema', () => { + describe('base schema (no item)', () => { + const schema = getAuthNValidationSchema(null) + + it('accepts a valid acr/level/defaultAuthNMethod set', async () => { + await expect(schema.isValid(baseValid)).resolves.toBe(true) + }) + + it('requires the acr name', async () => { + await expect(schema.validate({ ...baseValid, acr: undefined })).rejects.toThrow( + 'ACR name is required.', + ) + }) + + it('rejects a non-numeric level', async () => { + await expect(schema.validate({ ...baseValid, level: 'abc' })).rejects.toThrow( + 'Level must be a number.', + ) + }) + + it('rejects a non-integer level', async () => { + await expect(schema.validate({ ...baseValid, level: 1.5 })).rejects.toThrow( + 'Level must be an integer.', + ) + }) + + it('allows -1 as the minimum level for the base schema', async () => { + await expect(schema.isValid({ ...baseValid, level: -1 })).resolves.toBe(true) + }) + + it('rejects a level below -1', async () => { + await expect(schema.validate({ ...baseValid, level: -2 })).rejects.toThrow( + 'Level must be at least -1.', + ) + }) + + it('requires the default AuthN method', async () => { + await expect(schema.validate({ acr: 'x', level: 1 })).rejects.toThrow( + 'Default AuthN Method is required.', + ) + }) + }) + + describe('script schema', () => { + const item = { isCustomScript: true } as AuthNItem + const schema = getAuthNValidationSchema(item) + + it('raises the minimum level to 0', async () => { + await expect(schema.validate({ ...baseValid, level: -1 })).rejects.toThrow( + 'Level must be at least 0.', + ) + await expect(schema.isValid({ ...baseValid, level: 0 })).resolves.toBe(true) + }) + + it('permits optional nullable samlACR and description', async () => { + await expect( + schema.isValid({ ...baseValid, samlACR: null, description: null }), + ).resolves.toBe(true) + }) + }) + + describe('ldap schema', () => { + const item = { name: AUTH_METHOD_NAMES.DEFAULT_LDAP } as AuthNItem + const schema = getAuthNValidationSchema(item) + + const ldapValid = { + ...baseValid, + bindDN: 'cn=admin', + maxConnections: 2, + servers: ['ldap://host'], + baseDNs: ['o=jans'], + } + + it('accepts a valid ldap config', async () => { + await expect(schema.isValid(ldapValid)).resolves.toBe(true) + }) + + it('requires bindDN', async () => { + await expect(schema.validate({ ...ldapValid, bindDN: undefined })).rejects.toThrow( + 'Bind DN is required.', + ) + }) + + it('requires at least one server', async () => { + await expect(schema.validate({ ...ldapValid, servers: [] })).rejects.toThrow( + 'At least one server is required.', + ) + }) + + it('requires at least one base DN', async () => { + await expect(schema.validate({ ...ldapValid, baseDNs: [] })).rejects.toThrow( + 'At least one base DN is required.', + ) + }) + + it('requires maxConnections to be at least 1', async () => { + await expect(schema.validate({ ...ldapValid, maxConnections: 0 })).rejects.toThrow( + 'Max connections must be at least 1.', + ) + }) + }) + + describe('built-in schema', () => { + const item = { name: AUTH_METHOD_NAMES.SIMPLE_PASSWORD } as AuthNItem + const schema = getAuthNValidationSchema(item) + + it('permits optional nullable built-in fields', async () => { + await expect( + schema.isValid({ + ...baseValid, + samlACR: null, + description: null, + primaryKey: null, + passwordAttribute: null, + hashAlgorithm: null, + }), + ).resolves.toBe(true) + }) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Authentication/AgamaFlows/__tests__/helper/utils.test.ts b/admin-ui/plugins/auth-server/components/Authentication/AgamaFlows/__tests__/helper/utils.test.ts new file mode 100644 index 0000000000..19e3abd99b --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Authentication/AgamaFlows/__tests__/helper/utils.test.ts @@ -0,0 +1,118 @@ +import { + getErrorMessage, + transformAcrMappingsToTableData, + buildAcrMappingPayload, + buildAcrMappingDeletePayload, + prepareMappingsForUpdate, + prepareMappingsForDelete, + toActionData, +} from 'Plugins/auth-server/components/Authentication/AgamaFlows/helper/utils' + +describe('getErrorMessage', () => { + it('reads the message from an Error instance', () => { + expect(getErrorMessage(new Error('boom'))).toBe('boom') + }) + + it('uses the fallback for an Error with no message', () => { + expect(getErrorMessage(new Error(''), 'fallback')).toBe('fallback') + }) + + it('reads a message off a plain error-like object', () => { + expect(getErrorMessage({ message: 'plain error' })).toBe('plain error') + }) + + it('uses the fallback when there is no message at all', () => { + expect(getErrorMessage({}, 'default')).toBe('default') + }) +}) + +describe('transformAcrMappingsToTableData', () => { + it('returns an empty array when no mappings are provided', () => { + expect(transformAcrMappingsToTableData()).toEqual([]) + }) + + it('maps each entry to a mapping/source row', () => { + expect(transformAcrMappingsToTableData({ acr1: 'flow1', acr2: 'flow2' })).toEqual([ + { mapping: 'acr1', source: 'flow1' }, + { mapping: 'acr2', source: 'flow2' }, + ]) + }) +}) + +describe('buildAcrMappingPayload', () => { + it('uses an add op when there are no existing mappings', () => { + expect(buildAcrMappingPayload({ acr1: 'flow1' })).toEqual({ + requestBody: [{ path: '/acrMappings', value: { acr1: 'flow1' }, op: 'add' }], + }) + }) + + it('uses a replace op when existing mappings are present', () => { + expect(buildAcrMappingPayload({ acr1: 'flow1' }, { acr0: 'flow0' })).toEqual({ + requestBody: [{ path: '/acrMappings', value: { acr1: 'flow1' }, op: 'replace' }], + }) + }) +}) + +describe('buildAcrMappingDeletePayload', () => { + it('emits a remove op when clearing the last mapping', () => { + expect(buildAcrMappingDeletePayload({}, { acr0: 'flow0' })).toEqual({ + requestBody: [{ path: '/acrMappings', op: 'remove' }], + }) + }) + + it('emits a replace op when mappings remain and existing mappings were present', () => { + expect(buildAcrMappingDeletePayload({ acr1: 'flow1' }, { acr0: 'flow0' })).toEqual({ + requestBody: [{ path: '/acrMappings', value: { acr1: 'flow1' }, op: 'replace' }], + }) + }) + + it('emits an add op when mappings remain and there were no existing mappings', () => { + expect(buildAcrMappingDeletePayload({ acr1: 'flow1' })).toEqual({ + requestBody: [{ path: '/acrMappings', value: { acr1: 'flow1' }, op: 'add' }], + }) + }) +}) + +describe('prepareMappingsForUpdate', () => { + it('adds a new mapping without mutating the input', () => { + const current = { acr1: 'flow1' } + const result = prepareMappingsForUpdate(current, { mapping: 'acr2', source: 'flow2' }, false) + expect(result).toEqual({ acr1: 'flow1', acr2: 'flow2' }) + expect(current).toEqual({ acr1: 'flow1' }) + }) + + it('renames a key on edit when the mapping name changed', () => { + const result = prepareMappingsForUpdate( + { old: 'flow1' }, + { mapping: 'new', source: 'flow1' }, + true, + 'old', + ) + expect(result).toEqual({ new: 'flow1' }) + }) + + it('keeps the key on edit when the mapping name is unchanged', () => { + const result = prepareMappingsForUpdate( + { acr1: 'flow1' }, + { mapping: 'acr1', source: 'flow2' }, + true, + 'acr1', + ) + expect(result).toEqual({ acr1: 'flow2' }) + }) +}) + +describe('prepareMappingsForDelete', () => { + it('removes the target key without mutating the input', () => { + const current = { acr1: 'flow1', acr2: 'flow2' } + expect(prepareMappingsForDelete(current, 'acr1')).toEqual({ acr2: 'flow2' }) + expect(current).toEqual({ acr1: 'flow1', acr2: 'flow2' }) + }) +}) + +describe('toActionData', () => { + it('lifts the request body onto an action data object', () => { + const body = [{ path: '/acrMappings', op: 'add' as const, value: { acr1: 'flow1' } }] + expect(toActionData({ requestBody: body })).toEqual({ requestBody: body }) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/ConfigApiProperties/utils/__tests__/validations.test.ts b/admin-ui/plugins/auth-server/components/ConfigApiProperties/utils/__tests__/validations.test.ts new file mode 100644 index 0000000000..040dd15271 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/ConfigApiProperties/utils/__tests__/validations.test.ts @@ -0,0 +1,73 @@ +import { configApiPropertiesSchema } from 'Plugins/auth-server/components/ConfigApiProperties/utils/validations' + +describe('configApiPropertiesSchema', () => { + it('accepts an empty object (all fields nullable/optional)', async () => { + await expect(configApiPropertiesSchema.isValid({})).resolves.toBe(true) + }) + + it('accepts a populated valid config', async () => { + await expect( + configApiPropertiesSchema.isValid({ + serviceName: 'jans-config-api', + configOauthEnabled: true, + apiProtectionType: 'OAuth2', + apiApprovedIssuer: ['https://issuer.example.com'], + authIssuerUrl: 'https://issuer.example.com', + loggingLevel: 'INFO', + loggingLayout: 'text', + maxCount: 100, + }), + ).resolves.toBe(true) + }) + + it('rejects an unsupported api protection type', async () => { + await expect( + configApiPropertiesSchema.validate({ apiProtectionType: 'Basic' }), + ).rejects.toThrow('Invalid API protection type. Supported type is OAuth2') + }) + + it('rejects an empty approved issuer list', async () => { + await expect(configApiPropertiesSchema.validate({ apiApprovedIssuer: [] })).rejects.toThrow( + 'At least one approved issuer is required', + ) + }) + + it('rejects a malformed issuer url', async () => { + await expect( + configApiPropertiesSchema.validate({ authIssuerUrl: 'not-a-url' }), + ).rejects.toThrow('Invalid URL format') + }) + + it('rejects an unknown logging level', async () => { + await expect(configApiPropertiesSchema.validate({ loggingLevel: 'VERBOSE' })).rejects.toThrow( + 'Invalid logging level', + ) + }) + + it('rejects a negative maxCount', async () => { + await expect(configApiPropertiesSchema.validate({ maxCount: -1 })).rejects.toThrow( + 'Must be non-negative', + ) + }) + + it('rejects a maxCount over the ceiling', async () => { + await expect(configApiPropertiesSchema.validate({ maxCount: 10001 })).rejects.toThrow( + 'Must not exceed 10000', + ) + }) + + it('coerces an empty-string maxCount to null (treated as absent)', async () => { + // The number field carries a transform that maps '' -> null; feed the raw + // form string through a loosely-typed input to exercise it. + const input: Record = { maxCount: '' } + await expect(configApiPropertiesSchema.isValid(input)).resolves.toBe(true) + }) + + it('rejects a cors preflight max age above 24 hours', async () => { + await expect( + configApiPropertiesSchema.validate({ + corsConfigurationFilters: [{ corsPreflightMaxAge: 90000 }], + }), + ).rejects.toThrow('Must not exceed 24 hours (86400 seconds)') + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Logging/__tests__/utils.test.ts b/admin-ui/plugins/auth-server/components/Logging/__tests__/utils.test.ts new file mode 100644 index 0000000000..fdaeb7eae2 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Logging/__tests__/utils.test.ts @@ -0,0 +1,86 @@ +import { + LOG_LEVELS, + LOG_LAYOUTS, + getLoggingInitialValues, + getMergedValues, + getChangedFields, +} from 'Plugins/auth-server/components/Logging/utils' +import type { LoggingConfigLike } from 'Plugins/auth-server/components/Logging/types/LoggingTypes' + +describe('logging constants', () => { + it('exposes the supported levels and layouts', () => { + expect(LOG_LEVELS).toEqual(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR']) + expect(LOG_LAYOUTS).toEqual(['text', 'json']) + }) +}) + +describe('getLoggingInitialValues', () => { + it('returns first-choice defaults when no config is provided', () => { + expect(getLoggingInitialValues()).toEqual({ + loggingLevel: 'TRACE', + loggingLayout: 'text', + httpLoggingEnabled: false, + disableJdkLogger: false, + enabledOAuthAuditLogging: false, + }) + }) + + it('returns the same defaults for null', () => { + expect(getLoggingInitialValues(null)).toEqual(getLoggingInitialValues()) + }) + + it('maps a valid config through, coercing truthy values to booleans', () => { + // A truthy non-boolean (1) exercises the Boolean() coercion path; the field + // is typed boolean | null, so the whole fixture is cast to the param type. + const config = { + loggingLevel: 'ERROR', + loggingLayout: 'json', + httpLoggingEnabled: 1, + disableJdkLogger: true, + enabledOAuthAuditLogging: false, + } as object as LoggingConfigLike + expect(getLoggingInitialValues(config)).toEqual({ + loggingLevel: 'ERROR', + loggingLayout: 'json', + httpLoggingEnabled: true, + disableJdkLogger: true, + enabledOAuthAuditLogging: false, + }) + }) + + it('falls back to defaults for invalid level/layout', () => { + const values = getLoggingInitialValues({ + loggingLevel: 'VERBOSE', + loggingLayout: 'xml', + httpLoggingEnabled: false, + disableJdkLogger: false, + enabledOAuthAuditLogging: false, + }) + expect(values.loggingLevel).toBe('TRACE') + expect(values.loggingLayout).toBe('text') + }) +}) + +describe('getMergedValues', () => { + it('overlays updated fields onto the original', () => { + expect(getMergedValues({ a: 1, b: 2 }, { b: 3 })).toEqual({ a: 1, b: 3 }) + }) +}) + +describe('getChangedFields', () => { + it('reports only changed fields with old/new values', () => { + expect(getChangedFields({ a: 1, b: 2 }, { a: 1, b: 5 })).toEqual({ + b: { oldValue: 2, newValue: 5 }, + }) + }) + + it('ignores undefined new values', () => { + const original = { a: 1 } + const updated: { a: number } = { a: undefined as never } + expect(getChangedFields(original, updated)).toEqual({}) + }) + + it('returns an empty object when nothing changed', () => { + expect(getChangedFields({ a: 1 }, { a: 1 })).toEqual({}) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Logging/__tests__/validations.test.ts b/admin-ui/plugins/auth-server/components/Logging/__tests__/validations.test.ts new file mode 100644 index 0000000000..9c2a50e9eb --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Logging/__tests__/validations.test.ts @@ -0,0 +1,65 @@ +import { loggingValidationSchema } from 'Plugins/auth-server/components/Logging/validations' + +const validValues = { + loggingLevel: 'INFO', + loggingLayout: 'text', + httpLoggingEnabled: false, + disableJdkLogger: true, + enabledOAuthAuditLogging: false, +} + +describe('loggingValidationSchema', () => { + it('accepts a fully valid logging config', async () => { + await expect(loggingValidationSchema.isValid(validValues)).resolves.toBe(true) + }) + + it('accepts every supported logging level', async () => { + for (const level of ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR']) { + await expect( + loggingValidationSchema.isValid({ ...validValues, loggingLevel: level }), + ).resolves.toBe(true) + } + }) + + it('accepts every supported logging layout', async () => { + for (const layout of ['text', 'json']) { + await expect( + loggingValidationSchema.isValid({ ...validValues, loggingLayout: layout }), + ).resolves.toBe(true) + } + }) + + it('rejects an unknown logging level', async () => { + await expect( + loggingValidationSchema.validate({ ...validValues, loggingLevel: 'VERBOSE' }), + ).rejects.toThrow('Invalid logging level') + }) + + it('rejects an unknown logging layout', async () => { + await expect( + loggingValidationSchema.validate({ ...validValues, loggingLayout: 'xml' }), + ).rejects.toThrow('Invalid logging layout') + }) + + it('requires the logging level', async () => { + const { loggingLevel, ...rest } = validValues + void loggingLevel + await expect(loggingValidationSchema.validate(rest)).rejects.toThrow( + 'Logging level is required', + ) + }) + + it('requires the logging layout', async () => { + const { loggingLayout, ...rest } = validValues + void loggingLayout + await expect(loggingValidationSchema.validate(rest)).rejects.toThrow( + 'Logging layout is required', + ) + }) + + it('requires the boolean flags to be present', async () => { + const { httpLoggingEnabled, ...rest } = validValues + void httpLoggingEnabled + await expect(loggingValidationSchema.isValid(rest)).resolves.toBe(false) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Ssa/__tests__/helper/utils.test.ts b/admin-ui/plugins/auth-server/components/Ssa/__tests__/helper/utils.test.ts new file mode 100644 index 0000000000..92628429e2 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Ssa/__tests__/helper/utils.test.ts @@ -0,0 +1,117 @@ +import type { SsaFormValues } from 'Plugins/auth-server/components/Ssa/types' + +type AuditCall = { + action: string + resource: string + message: string + client_id: string + payload: Record +} + +// Collaborators have their own suites; stub them so the assertions target this +// helper's payload assembly, audit action selection, and error handling. +const mockLogAudit = jest.fn() +jest.mock('Utils/AuditLogger', () => ({ + logAuditUserAction: (payload: AuditCall) => mockLogAudit(payload), +})) + +jest.mock('@/audit', () => ({ + getCurrentAuditContext: () => ({ client_id: 'client-1', userinfo: { sub: 'admin' } }), +})) + +const mockResolve = jest.fn((e: Error) => e.message) +jest.mock('@/utils/apiErrorMessage', () => ({ + resolveApiErrorMessage: (e: Error) => mockResolve(e), +})) + +const mockLoggerError = jest.fn() +jest.mock('@/utils/logger', () => ({ + logger: { error: (m: string, d?: string) => mockLoggerError(m, d) }, +})) + +jest.mock('Plugins/auth-server/redux/audit/Resources', () => ({ SSA: 'ssa' })) + +import { + logSsaCreation, + logSsaDeletion, + getErrorMessage, +} from 'Plugins/auth-server/components/Ssa/helper/utils' + +const lastAudit = (): AuditCall => mockLogAudit.mock.calls[0][0] as AuditCall + +beforeEach(() => { + mockLogAudit.mockReset().mockResolvedValue(undefined) + mockResolve.mockClear() + mockLoggerError.mockReset() +}) + +const baseForm = { + software_id: 'sw-1', + one_time_use: true, + org_id: 'org-1', + description: 'a description', + software_roles: ['role'], + rotate_ssa: true, + grant_types: ['authorization_code'], + is_expirable: false, + expirationDate: null, +} as object as SsaFormValues + +describe('logSsaCreation', () => { + it('logs a CREATE action with the serialized payload', async () => { + await logSsaCreation(baseForm) + const call = lastAudit() + expect(call.action).toBe('CREATE') + expect(call.resource).toBe('ssa') + expect(call.client_id).toBe('client-1') + expect(call.payload).toMatchObject({ software_id: 'sw-1', org_id: 'org-1' }) + }) + + it('serializes an expiration date to an ISO string', async () => { + const withDate = { + ...baseForm, + is_expirable: true, + expirationDate: new Date('2030-01-02T03:04:05.000Z'), + } as object as SsaFormValues + await logSsaCreation(withDate) + expect(lastAudit().payload.expirationDate).toBe('2030-01-02T03:04:05.000Z') + }) + + it('uses a custom message when provided', async () => { + await logSsaCreation(baseForm, 'custom message') + expect(lastAudit().message).toBe('custom message') + }) + + it('swallows and logs errors from the audit logger', async () => { + mockLogAudit.mockRejectedValueOnce(new Error('audit down')) + await expect(logSsaCreation(baseForm)).resolves.toBeUndefined() + expect(mockLoggerError).toHaveBeenCalledWith('Failed to log SSA creation:', 'audit down') + }) +}) + +describe('logSsaDeletion', () => { + it('logs a DELETION action carrying the jti', async () => { + await logSsaDeletion('jti-123', { software_id: 'sw-1' }) + const call = lastAudit() + expect(call.action).toBe('DELETION') + expect(call.payload).toMatchObject({ jti: 'jti-123', software_id: 'sw-1' }) + }) + + it('defaults the payload to just the jti when none is provided', async () => { + await logSsaDeletion('jti-999') + expect(lastAudit().payload).toEqual({ jti: 'jti-999' }) + }) + + it('swallows and logs deletion audit errors', async () => { + mockLogAudit.mockRejectedValueOnce(new Error('nope')) + await expect(logSsaDeletion('jti-1')).resolves.toBeUndefined() + expect(mockLoggerError).toHaveBeenCalledWith('Failed to log SSA deletion:', 'nope') + }) +}) + +describe('getErrorMessage', () => { + it('delegates to resolveApiErrorMessage', () => { + expect(getErrorMessage(new Error('resolved'))).toBe('resolved') + expect(mockResolve).toHaveBeenCalled() + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Ssa/__tests__/helper/validations.test.ts b/admin-ui/plugins/auth-server/components/Ssa/__tests__/helper/validations.test.ts new file mode 100644 index 0000000000..fdf472d9ad --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Ssa/__tests__/helper/validations.test.ts @@ -0,0 +1,92 @@ +// Messages come from i18n.t(); echo the key back so assertions match on identity +// without depending on the translation bundle. +jest.mock('@/i18n', () => ({ + __esModule: true, + default: { t: (key: string) => key }, +})) + +import { getSsaValidationSchema } from 'Plugins/auth-server/components/Ssa/helper/validations' + +const schema = getSsaValidationSchema() + +const validValues = { + software_id: 'abc', + org_id: 'org', + description: 'a valid description', + software_roles: ['role-1'], + grant_types: ['authorization_code'], + one_time_use: true, + rotate_ssa: true, + is_expirable: false, + expirationDate: null, +} + +describe('getSsaValidationSchema', () => { + it('accepts a fully valid, non-expirable SSA', async () => { + await expect(schema.isValid(validValues)).resolves.toBe(true) + }) + + it('requires the software id', async () => { + await expect(schema.validate({ ...validValues, software_id: '' })).rejects.toThrow( + 'validation_messages.software_id_required', + ) + }) + + it('enforces a minimum software id length', async () => { + await expect(schema.validate({ ...validValues, software_id: 'ab' })).rejects.toThrow( + 'validation_messages.software_id_min_length', + ) + }) + + it('requires the org id', async () => { + await expect(schema.validate({ ...validValues, org_id: '' })).rejects.toThrow( + 'validation_messages.org_id_required', + ) + }) + + it('enforces a minimum description length', async () => { + await expect(schema.validate({ ...validValues, description: 'short' })).rejects.toThrow( + 'validation_messages.description_min_length', + ) + }) + + it('requires at least one software role', async () => { + await expect(schema.validate({ ...validValues, software_roles: [] })).rejects.toThrow( + 'validation_messages.software_roles_min', + ) + }) + + it('requires at least one grant type', async () => { + await expect(schema.validate({ ...validValues, grant_types: [] })).rejects.toThrow( + 'validation_messages.grant_types_min', + ) + }) + + describe('expiration date rules', () => { + it('requires an expiration date when the SSA is expirable', async () => { + await expect( + schema.validate({ ...validValues, is_expirable: true, expirationDate: null }), + ).rejects.toThrow('validation_messages.expiration_date_required') + }) + + it('rejects a past expiration date when expirable', async () => { + await expect( + schema.validate({ + ...validValues, + is_expirable: true, + expirationDate: '2000-01-01', + }), + ).rejects.toThrow('validation_messages.expiration_date_future') + }) + + it('accepts a future expiration date when expirable', async () => { + await expect( + schema.isValid({ + ...validValues, + is_expirable: true, + expirationDate: '2999-01-01', + }), + ).resolves.toBe(true) + }) + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Ssa/utils/__tests__/formValidationUtils.test.ts b/admin-ui/plugins/auth-server/components/Ssa/utils/__tests__/formValidationUtils.test.ts new file mode 100644 index 0000000000..96776f8c44 --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Ssa/utils/__tests__/formValidationUtils.test.ts @@ -0,0 +1,64 @@ +import { + getSsaInitialValues, + hasFormChanges, + shouldDisableApplyButton, +} from 'Plugins/auth-server/components/Ssa/utils/formValidationUtils' + +describe('getSsaInitialValues', () => { + it('returns the documented empty defaults', () => { + expect(getSsaInitialValues()).toEqual({ + software_id: '', + one_time_use: true, + org_id: '', + description: '', + software_roles: [], + rotate_ssa: true, + grant_types: [], + is_expirable: false, + expirationDate: null, + }) + }) + + it('returns a fresh object on each call', () => { + const first = getSsaInitialValues() + const second = getSsaInitialValues() + expect(first).not.toBe(second) + expect(first.software_roles).not.toBe(second.software_roles) + }) +}) + +describe('hasFormChanges', () => { + it('is true when the form is dirty', () => { + expect(hasFormChanges(true, [], {})).toBe(true) + }) + + it('is true when attributes are selected', () => { + expect(hasFormChanges(false, ['a'], {})).toBe(true) + }) + + it('is true when there are modified fields', () => { + expect(hasFormChanges(false, [], { name: true })).toBe(true) + }) + + it('is false when nothing has changed', () => { + expect(hasFormChanges(false, [], {})).toBe(false) + }) +}) + +describe('shouldDisableApplyButton', () => { + it('disables while submitting', () => { + expect(shouldDisableApplyButton(true, true, true, {}, [])).toBe(true) + }) + + it('disables when there are no changes', () => { + expect(shouldDisableApplyButton(false, false, true, {}, [])).toBe(true) + }) + + it('disables when the form is invalid', () => { + expect(shouldDisableApplyButton(false, true, false, {}, [])).toBe(true) + }) + + it('enables when there are valid changes and it is not submitting', () => { + expect(shouldDisableApplyButton(false, true, true, {}, [])).toBe(false) + }) +}) diff --git a/admin-ui/plugins/fido/__tests__/helper/utils.test.ts b/admin-ui/plugins/fido/__tests__/helper/utils.test.ts new file mode 100644 index 0000000000..13b97a5687 --- /dev/null +++ b/admin-ui/plugins/fido/__tests__/helper/utils.test.ts @@ -0,0 +1,143 @@ +import type { TFunction } from 'i18next' +import { + transformToFormValues, + createFidoConfigPayload, + getModifiedFields, + buildChangedFieldOperations, +} from 'Plugins/fido/helper/utils' +import { fidoConstants } from 'Plugins/fido/helper/constants' +import type { + DynamicConfigFormValues, + StaticConfigFormValues, + FidoFormValues, +} from 'Plugins/fido/types' + +const t = ((key: string) => key) as TFunction + +// The transforms accept API config objects; fixtures intentionally use raw +// strings (e.g. 'true', '') to exercise coercion, so they enter through a +// loosely-typed record cast to the parameter type. +type LooseConfig = Parameters[0] +const cfg = (value: Record): LooseConfig => + value as object as LooseConfig + +describe('transformToFormValues (dynamic)', () => { + it('maps a dynamic config, coercing booleans and defaulting empties', () => { + const result = transformToFormValues( + cfg({ + issuer: 'https://issuer', + useLocalCache: true, + metricReporterEnabled: 'true', + cleanServiceInterval: '', + }), + fidoConstants.DYNAMIC, + ) as DynamicConfigFormValues + expect(result.issuer).toBe('https://issuer') + expect(result.useLocalCache).toBe(true) + expect(result.metricReporterEnabled).toBe(true) + expect(result.baseEndpoint).toBe('') + }) + + it('defaults to the dynamic shape when the type is unknown', () => { + const result = transformToFormValues({ issuer: 'x' }) as DynamicConfigFormValues + expect(result.issuer).toBe('x') + }) +}) + +describe('transformToFormValues (static)', () => { + it('maps a static config, splitting rp origins and filtering hints', () => { + const result = transformToFormValues( + cfg({ + authenticatorCertsFolder: '/certs', + userAutoEnrollment: 'true', + rp: [{ id: 'rp1', origins: ['https://a', 'https://b'] }], + hints: ['not-a-real-hint'], + }), + fidoConstants.STATIC, + ) as StaticConfigFormValues + expect(result.authenticatorCertsFolder).toBe('/certs') + expect(result.userAutoEnrollment).toBe(true) + expect(result.requestedParties).toEqual([{ key: 'rp1', value: 'https://a,https://b' }]) + // Unknown hints are dropped by arrayValidationWithSchema. + expect(result.hints).toEqual([]) + }) +}) + +describe('createFidoConfigPayload (dynamic)', () => { + it('applies dynamic changes onto a cloned payload without mutating the source', () => { + const source = { issuer: 'orig', fido2Configuration: {} } + const data = transformToFormValues(source, fidoConstants.DYNAMIC) as DynamicConfigFormValues + const result = createFidoConfigPayload({ + fidoConfiguration: source, + data: { ...data, issuer: 'changed' }, + type: fidoConstants.DYNAMIC, + }) + expect(result.data.issuer).toBe('changed') + expect(source.issuer).toBe('orig') + }) +}) + +describe('createFidoConfigPayload (static)', () => { + it('converts numeric string fields and writes fido2Configuration', () => { + const source = { fido2Configuration: {} } + const data = transformToFormValues( + { fido2Configuration: {} } as never, + fidoConstants.STATIC, + ) as StaticConfigFormValues + const result = createFidoConfigPayload({ + fidoConfiguration: source, + data: { ...data, unfinishedRequestExpiration: '120' }, + type: fidoConstants.STATIC, + }) + expect(result.data.fido2Configuration?.unfinishedRequestExpiration).toBe(120) + }) +}) + +describe('getModifiedFields', () => { + it('returns only the fields that differ from the original config', () => { + const original = { issuer: 'orig', baseEndpoint: 'base' } + const current = transformToFormValues( + original, + fidoConstants.DYNAMIC, + ) as DynamicConfigFormValues + const modified = getModifiedFields( + { ...current, issuer: 'changed' } as FidoFormValues, + original, + fidoConstants.DYNAMIC, + ) + expect(modified).toEqual({ issuer: 'changed' }) + }) +}) + +describe('buildChangedFieldOperations', () => { + it('returns no operations when nothing changed', () => { + const values = transformToFormValues( + { issuer: 'x' }, + fidoConstants.DYNAMIC, + ) as DynamicConfigFormValues + expect( + buildChangedFieldOperations( + values as FidoFormValues, + values as FidoFormValues, + fidoConstants.DYNAMIC, + t, + ), + ).toEqual([]) + }) + + it('records a labelled operation for a changed field', () => { + const initial = transformToFormValues( + { issuer: 'x' }, + fidoConstants.DYNAMIC, + ) as DynamicConfigFormValues + const current = { ...initial, issuer: 'y' } + const ops = buildChangedFieldOperations( + initial as FidoFormValues, + current as FidoFormValues, + fidoConstants.DYNAMIC, + t, + ) + expect(ops).toHaveLength(1) + expect(ops[0].value).toBe('y') + }) +}) diff --git a/admin-ui/plugins/saml/__tests__/utils.test.ts b/admin-ui/plugins/saml/__tests__/utils.test.ts new file mode 100644 index 0000000000..ba48e1bf4c --- /dev/null +++ b/admin-ui/plugins/saml/__tests__/utils.test.ts @@ -0,0 +1,155 @@ +import { + transformToFormValues, + transformToIdentityProviderFormValues, + transformToWebsiteSsoServiceProviderFormValues, + separateConfigFields, + cleanOptionalFields, + buildIdentityProviderPayload, + buildWebsiteSsoServiceProviderPayload, +} from 'Plugins/saml/helper/utils' +import type { FormValues } from 'Plugins/saml/types' + +// The SP payload builder expects a fully-shaped form-values object; fixtures use +// partial inputs (with raw strings to exercise coercion), cast to the param type. +type SpPayloadInput = Parameters[0] +const spInput = (value: Record): SpPayloadInput => + value as object as SpPayloadInput + +describe('transformToFormValues', () => { + it('coerces the SAML configuration fields', () => { + expect( + transformToFormValues({ enabled: true, selectedIdp: 'idp1', applicationName: 'app' }), + ).toEqual({ + enabled: true, + selectedIdp: 'idp1', + ignoreValidation: false, + applicationName: 'app', + }) + }) + + it('defaults everything for an undefined configuration', () => { + expect(transformToFormValues(undefined)).toEqual({ + enabled: false, + selectedIdp: '', + ignoreValidation: false, + applicationName: '', + }) + }) +}) + +describe('transformToIdentityProviderFormValues', () => { + it('prefers nested config values and flags imported metadata', () => { + const result = transformToIdentityProviderFormValues({ + name: 'idp1', + displayName: 'IdP One', + idpMetaDataFN: 'idp.xml', + config: { idpEntityId: 'entity-from-config' }, + idpEntityId: 'entity-from-root', + }) + expect(result.name).toBe('idp1') + expect(result.idpEntityId).toBe('entity-from-config') + expect(result.metaDataFileImportedFlag).toBe(true) + }) + + it('falls back to root values and clears the import flag when no file name', () => { + const result = transformToIdentityProviderFormValues({ + name: 'idp2', + singleSignOnServiceUrl: 'https://sso', + }) + expect(result.singleSignOnServiceUrl).toBe('https://sso') + expect(result.metaDataFileImportedFlag).toBe(false) + }) +}) + +describe('transformToWebsiteSsoServiceProviderFormValues', () => { + it('defaults nested saml metadata and flags imported metadata', () => { + const result = transformToWebsiteSsoServiceProviderFormValues({ + name: 'sp1', + spMetaDataFN: 'sp.xml', + spMetaDataSourceType: 'file', + }) + expect(result.name).toBe('sp1') + expect(result.metaDataFileImportedFlag).toBe(true) + expect(result.samlMetadata.entityId).toBe('') + }) + + it('defaults everything for a null configuration', () => { + const result = transformToWebsiteSsoServiceProviderFormValues(null) + expect(result.enabled).toBe(false) + expect(result.spMetaDataSourceType).toBe('') + expect(result.releasedAttributes).toEqual([]) + }) +}) + +describe('separateConfigFields', () => { + it('splits config fields from root fields and trims strings', () => { + const { rootFields, configData } = separateConfigFields({ + name: ' sp1 ', + idpEntityId: ' entity ', + } as FormValues) + expect(rootFields.name).toBe('sp1') + expect(configData.idpEntityId).toBe('entity') + }) +}) + +describe('cleanOptionalFields', () => { + it('drops null, undefined, and empty-string fields', () => { + expect(cleanOptionalFields({ a: 'x', b: null, c: undefined, d: ' ' })).toEqual({ a: 'x' }) + }) + + it('keeps empty strings when removeEmptyStrings is false', () => { + expect(cleanOptionalFields({ a: '' }, false)).toEqual({ a: '' }) + }) + + it('drops empty arrays but keeps populated ones', () => { + expect(cleanOptionalFields({ a: [], b: ['x'] })).toEqual({ b: ['x'] }) + }) + + it('recurses into nested objects and drops empty ones', () => { + expect(cleanOptionalFields({ nested: { a: 'x', b: '' }, empty: { c: '' } })).toEqual({ + nested: { a: 'x' }, + }) + }) +}) + +describe('buildIdentityProviderPayload', () => { + it('coerces types and defaults status fields, preferring config data', () => { + const payload = buildIdentityProviderPayload( + { name: 'idp1', enabled: 'true' }, + { idpEntityId: 'entity' }, + ) + expect(payload.name).toBe('idp1') + expect(payload.enabled).toBe(true) + expect(payload.idpEntityId).toBe('entity') + expect(payload.validationStatus).toBe('In Progress') + expect(payload.status).toBe('active') + }) + + it('includes the inum only when provided', () => { + expect(buildIdentityProviderPayload({}, {}, 'inum-1').inum).toBe('inum-1') + expect(buildIdentityProviderPayload({}, {}).inum).toBeUndefined() + }) +}) + +describe('buildWebsiteSsoServiceProviderPayload', () => { + it('coerces types and defaults status fields', () => { + const payload = buildWebsiteSsoServiceProviderPayload( + spInput({ + name: 'sp1', + enabled: 'false', + spMetaDataSourceType: 'file', + samlMetadata: { entityId: 'e' }, + }), + ) + expect(payload.name).toBe('sp1') + expect(payload.enabled).toBe(false) + expect(payload.samlMetadata.entityId).toBe('e') + expect(payload.status).toBe('active') + }) + + it('includes the inum only when provided', () => { + expect( + buildWebsiteSsoServiceProviderPayload(spInput({ samlMetadata: {} }), 'inum-9').inum, + ).toBe('inum-9') + }) +}) diff --git a/admin-ui/plugins/saml/__tests__/validations.test.ts b/admin-ui/plugins/saml/__tests__/validations.test.ts new file mode 100644 index 0000000000..5b7af6f5ed --- /dev/null +++ b/admin-ui/plugins/saml/__tests__/validations.test.ts @@ -0,0 +1,106 @@ +import type { TFunction } from 'i18next' +import { + samlConfigurationValidationSchema, + websiteSsoIdentityProviderValidationSchema, + websiteSsoServiceProviderValidationSchema, +} from 'Plugins/saml/helper/validations' + +// The factory schemas only use t() to build message strings; echo the key so +// assertions match on identity. Interpolated fields render as " is Required!". +const t = ((key: string) => key) as TFunction + +describe('samlConfigurationValidationSchema', () => { + it('accepts a disabled configuration without a selected IdP', async () => { + await expect( + samlConfigurationValidationSchema.isValid({ enabled: false, selectedIdp: '' }), + ).resolves.toBe(true) + }) + + it('requires a selected IdP when SAML is enabled', async () => { + await expect( + samlConfigurationValidationSchema.validate({ enabled: true, selectedIdp: '' }), + ).rejects.toThrow('Selected IdP is required when SAML is enabled.') + }) + + it('accepts an enabled configuration with a selected IdP', async () => { + await expect( + samlConfigurationValidationSchema.isValid({ enabled: true, selectedIdp: 'idp-1' }), + ).resolves.toBe(true) + }) +}) + +describe('websiteSsoIdentityProviderValidationSchema', () => { + const schema = websiteSsoIdentityProviderValidationSchema(t) + + // The name/displayName fields disallow whitespace, so fixtures avoid spaces. + const validImported = { + name: 'idp1', + displayName: 'IdP-One', + enabled: true, + metaDataFileImportedFlag: true, + idpMetaDataFN: 'idp-metadata.xml', + } + + it('accepts a valid imported-metadata IdP', async () => { + await expect(schema.isValid(validImported)).resolves.toBe(true) + }) + + it('requires the name', async () => { + await expect(schema.validate({ ...validImported, name: '' })).rejects.toThrow() + }) + + it('rejects a name containing spaces', async () => { + await expect(schema.validate({ ...validImported, name: 'bad name' })).rejects.toThrow( + 'errors.cannot_contain_spaces', + ) + }) + + it('requires manual fields when metadata is not imported', async () => { + await expect( + schema.validate({ + name: 'idp1', + displayName: 'IdP One', + enabled: true, + metaDataFileImportedFlag: false, + }), + ).rejects.toThrow() + }) + + it('rejects a malformed single-logout url', async () => { + await expect( + schema.validate({ ...validImported, singleLogoutServiceUrl: 'not a url' }), + ).rejects.toThrow() + }) +}) + +describe('websiteSsoServiceProviderValidationSchema', () => { + const schema = websiteSsoServiceProviderValidationSchema(t) + + // name/displayName disallow whitespace, so fixtures avoid spaces. + const validSp = { + name: 'sp1', + displayName: 'SP-One', + spMetaDataSourceType: 'url', + samlMetadata: {}, + } + + it('accepts a valid non-manual service provider', async () => { + await expect(schema.isValid(validSp)).resolves.toBe(true) + }) + + it('requires the metadata source type', async () => { + await expect(schema.validate({ ...validSp, spMetaDataSourceType: '' })).rejects.toThrow( + 'fields.metadata_location is Required!', + ) + }) + + it('requires manual metadata fields when the source type is manual', async () => { + await expect( + schema.validate({ + ...validSp, + spMetaDataSourceType: 'manual', + samlMetadata: {}, + }), + ).rejects.toThrow() + }) +}) diff --git a/admin-ui/plugins/scripts/components/helper/__tests__/utils.test.ts b/admin-ui/plugins/scripts/components/helper/__tests__/utils.test.ts new file mode 100644 index 0000000000..5385ba26f7 --- /dev/null +++ b/admin-ui/plugins/scripts/components/helper/__tests__/utils.test.ts @@ -0,0 +1,125 @@ +import type { TFunction } from 'i18next' +import { + getApiErrorDetail, + getModuleProperty, + transformToFormValues, + buildChangedFieldOperations, +} from 'Plugins/scripts/components/helper/utils' +import type { FormValues } from 'Plugins/scripts/components/types/forms' +import type { ModuleProperty } from 'Plugins/scripts/components/types/customScript' + +const t = ((key: string) => key) as TFunction + +describe('getApiErrorDetail', () => { + it('returns an empty string for a nullish error', () => { + expect(getApiErrorDetail(null)).toBe('') + expect(getApiErrorDetail(undefined)).toBe('') + }) + + it('returns a string error verbatim', () => { + expect(getApiErrorDetail('bad thing')).toBe('bad thing') + }) + + it('reads a string response body', () => { + expect(getApiErrorDetail({ response: { data: 'server error' } })).toBe('server error') + }) + + it('reads a message off a structured response body', () => { + expect(getApiErrorDetail({ response: { data: { message: 'structured' } } })).toBe('structured') + }) + + it('falls back to the error message', () => { + expect(getApiErrorDetail(new Error('plain'))).toBe('plain') + }) +}) + +describe('getModuleProperty', () => { + it('returns the matching value2 for a key', () => { + expect(getModuleProperty('usage_type', [{ value1: 'usage_type', value2: 'interactive' }])).toBe( + 'interactive', + ) + }) + + it('returns undefined when the key is absent', () => { + expect(getModuleProperty('missing', [{ value1: 'other', value2: 'x' }])).toBeUndefined() + }) + + it('returns undefined when there are no properties', () => { + expect(getModuleProperty('usage_type')).toBeUndefined() + }) +}) + +describe('transformToFormValues', () => { + it('fills defaults for a minimal item', () => { + const result = transformToFormValues({ name: 'my_script' }) + expect(result).toMatchObject({ + name: 'my_script', + description: '', + scriptType: '', + programmingLanguage: 'python', + level: 1, + script: '', + aliases: [], + enabled: true, + action_message: '', + }) + expect(result.moduleProperties).toEqual([]) + expect(result.configurationProperties).toEqual([]) + }) + + it('normalizes key/value properties, trimming whitespace', () => { + // normalizeProperty also accepts the legacy key/value shape; cast the raw + // input to the declared moduleProperties element type to exercise it. + const result = transformToFormValues({ + name: 's', + moduleProperties: [{ key: ' k ', value: ' v ' } as object as ModuleProperty], + }) + expect(result.moduleProperties).toEqual([{ value1: 'k', value2: 'v' }]) + }) + + it('preserves an explicit enabled=false', () => { + expect(transformToFormValues({ name: 's', enabled: false }).enabled).toBe(false) + }) +}) + +describe('buildChangedFieldOperations', () => { + const base: FormValues = { + name: 'orig', + description: '', + scriptType: 'type-a', + programmingLanguage: 'python', + level: 1, + script: 'print(1)', + aliases: [], + moduleProperties: [], + configurationProperties: [], + enabled: true, + action_message: '', + } + + it('returns no operations when nothing changed', () => { + expect(buildChangedFieldOperations(base, { ...base }, t)).toEqual([]) + }) + + it('records a changed simple field', () => { + const ops = buildChangedFieldOperations(base, { ...base, name: 'updated' }, t) + expect(ops).toContainEqual({ path: 'fields.name', value: 'updated' }) + }) + + it('records added and removed module properties', () => { + const current = { ...base, moduleProperties: [{ value1: 'usage_type', value2: 'interactive' }] } + const ops = buildChangedFieldOperations(base, current, t) + expect(ops).toContainEqual({ + path: 'fields.module_properties [usage_type]', + value: 'interactive', + }) + }) + + it('truncates a long changed script preview', () => { + const longScript = 'x'.repeat(80) + const ops = buildChangedFieldOperations(base, { ...base, script: longScript }, t) + const scriptOp = ops.find((op) => op.path === 'fields.script') + expect(scriptOp?.value).toMatch(/\.\.\.$/) + expect(String(scriptOp?.value).length).toBeLessThanOrEqual(43) + }) +}) diff --git a/admin-ui/plugins/scripts/components/helper/__tests__/validations.test.ts b/admin-ui/plugins/scripts/components/helper/__tests__/validations.test.ts new file mode 100644 index 0000000000..734fb7c04f --- /dev/null +++ b/admin-ui/plugins/scripts/components/helper/__tests__/validations.test.ts @@ -0,0 +1,105 @@ +import type { TFunction } from 'i18next' +import { getCustomScriptValidationSchema } from 'Plugins/scripts/components/helper/validations' +import { DEFAULT_SCRIPT_TYPE } from '@/constants' + +// The schema only uses t() to produce message strings; echo the key back so the +// assertions can match on message identity without pulling in the i18n bundle. +const t = ((key: string) => key) as TFunction + +const schema = getCustomScriptValidationSchema(t) + +const validBase = { + name: 'my_script', + description: '', + scriptType: 'other_type', + programmingLanguage: 'python', + level: 1, + script: 'print(1)', + moduleProperties: [], +} + +describe('getCustomScriptValidationSchema', () => { + it('accepts a fully valid non-person script', async () => { + await expect(schema.isValid(validBase)).resolves.toBe(true) + }) + + it('rejects a name shorter than 3 characters', async () => { + await expect(schema.validate({ ...validBase, name: 'ab' })).rejects.toThrow( + 'messages.script_name_min', + ) + }) + + it('rejects a name with invalid characters', async () => { + await expect(schema.validate({ ...validBase, name: 'bad name!' })).rejects.toThrow( + 'messages.script_name_pattern', + ) + }) + + it('requires the script type', async () => { + await expect(schema.validate({ ...validBase, scriptType: '' })).rejects.toThrow( + 'messages.script_type_required', + ) + }) + + it('requires the programming language', async () => { + // An empty string trips the min-length rule before the required rule. + await expect(schema.validate({ ...validBase, programmingLanguage: '' })).rejects.toThrow( + 'messages.script_language_min', + ) + }) + + it('rejects a programming language shorter than 3 characters', async () => { + await expect(schema.validate({ ...validBase, programmingLanguage: 'py' })).rejects.toThrow( + 'messages.script_language_min', + ) + }) + + it('rejects a negative level', async () => { + await expect(schema.validate({ ...validBase, level: -1 })).rejects.toThrow( + 'messages.script_level_non_negative', + ) + }) + + it('requires the script body', async () => { + await expect(schema.validate({ ...validBase, script: '' })).rejects.toThrow( + 'messages.script_code_required', + ) + }) + + describe('person authentication usage_type rules', () => { + const personBase = { ...validBase, scriptType: DEFAULT_SCRIPT_TYPE } + + it('requires a usage_type module property', async () => { + await expect(schema.validate({ ...personBase, moduleProperties: [] })).rejects.toThrow( + 'messages.script_usage_type_required', + ) + }) + + it('rejects a blank usage_type value', async () => { + await expect( + schema.validate({ + ...personBase, + moduleProperties: [{ value1: 'usage_type', value2: ' ' }], + }), + ).rejects.toThrow('messages.script_usage_type_required') + }) + + it('rejects an unsupported usage_type value', async () => { + await expect( + schema.validate({ + ...personBase, + moduleProperties: [{ value1: 'usage_type', value2: 'sometimes' }], + }), + ).rejects.toThrow('messages.script_usage_type_valid') + }) + + it('accepts a supported usage_type value', async () => { + await expect( + schema.isValid({ + ...personBase, + moduleProperties: [{ value1: 'usage_type', value2: 'interactive' }], + }), + ).resolves.toBe(true) + }) + }) +}) diff --git a/admin-ui/plugins/user-management/__tests__/helper/utils.test.ts b/admin-ui/plugins/user-management/__tests__/helper/utils.test.ts new file mode 100644 index 0000000000..c60450d8f8 --- /dev/null +++ b/admin-ui/plugins/user-management/__tests__/helper/utils.test.ts @@ -0,0 +1,129 @@ +import type { CustomUser } from 'Plugins/user-management/types' + +// Collaborators have their own suites; stub them so the assertions target this +// helper's redaction, audit action selection, and webhook wiring. +type AuditRecord = { + action: string + resource: string + message: string + payload: CustomUser & { userPassword?: string; jsonPatchString?: string; inum?: string } +} +const mockLogAudit = jest.fn() +jest.mock('Utils/AuditLogger', () => ({ + logAuditUserAction: (record: AuditRecord) => mockLogAudit(record), +})) + +jest.mock('@/audit', () => ({ + getCurrentAuditContext: () => ({ client_id: 'client-1', userinfo: { sub: 'admin' } }), + CREATE: 'CREATE', + UPDATE: 'UPDATE', + DELETION: 'DELETION', +})) + +jest.mock('@/utils/apiErrorMessage', () => ({ + resolveApiErrorMessage: (e: Error) => `resolved:${e.message}`, +})) + +const mockTriggerWebhook = jest.fn() +jest.mock('@/utils/triggerWebhookForFeature', () => ({ + triggerWebhookForFeature: (data: Record, feature: string) => + mockTriggerWebhook(data, feature), +})) + +const mockLoggerError = jest.fn() +jest.mock('@/utils/logger', () => ({ logger: { error: (m: string) => mockLoggerError(m) } })) + +import { + logUserCreation, + logUserUpdate, + logUserDeletion, + logPasswordChange, + getErrorMessage, + triggerUserWebhook, +} from 'Plugins/user-management/helper/utils' +import { adminUiFeatures } from '@/constants' + +const lastAudit = (): AuditRecord => mockLogAudit.mock.calls[0][0] as AuditRecord + +beforeEach(() => { + mockLogAudit.mockReset().mockResolvedValue(undefined) + mockTriggerWebhook.mockReset() + mockLoggerError.mockReset() +}) + +describe('logUserCreation', () => { + it('logs a CREATE action and redacts the password', async () => { + await logUserCreation( + {} as CustomUser, + { + userId: 'jdoe', + userPassword: 'secret', + } as CustomUser, + ) + const call = lastAudit() + expect(call.action).toBe('CREATE') + expect(call.payload.userPassword).toBe('[REDACTED]') + }) + + it('redacts sensitive custom attribute values', async () => { + await logUserCreation( + {} as CustomUser, + { + customAttributes: [{ name: 'userPassword', values: ['secret'] }], + } as object as CustomUser, + ) + const attr = lastAudit().payload.customAttributes?.[0] + expect(attr?.values).toEqual([{ value: '[REDACTED]' }]) + }) + + it('swallows and logs audit errors', async () => { + mockLogAudit.mockRejectedValueOnce(new Error('down')) + await expect(logUserCreation({} as CustomUser, {} as CustomUser)).resolves.toBeUndefined() + expect(mockLoggerError).toHaveBeenCalledWith('Failed to log user creation:') + }) +}) + +describe('logUserUpdate', () => { + it('logs an UPDATE action', async () => { + await logUserUpdate({} as CustomUser, { userId: 'jdoe' } as CustomUser) + expect(lastAudit().action).toBe('UPDATE') + }) +}) + +describe('logUserDeletion', () => { + it('logs a DELETION action carrying the inum', async () => { + await logUserDeletion('inum-1', { userId: 'jdoe' } as CustomUser) + const call = lastAudit() + expect(call.action).toBe('DELETION') + expect(call.payload.inum).toBe('inum-1') + }) +}) + +describe('logPasswordChange', () => { + it('logs an UPDATE action and rewrites a json patch string', async () => { + await logPasswordChange('inum-1', { + jsonPatchString: '[{"op":"replace","path":"/userPassword","value":"secret"}]', + }) + const call = lastAudit() + expect(call.action).toBe('UPDATE') + expect(call.payload.jsonPatchString).toContain('[REDACTED]') + }) +}) + +describe('getErrorMessage', () => { + it('delegates to resolveApiErrorMessage', () => { + expect(getErrorMessage(new Error('oops'))).toBe('resolved:oops') + }) +}) + +describe('triggerUserWebhook', () => { + it('triggers the default users_edit feature', () => { + triggerUserWebhook({ userId: 'jdoe' } as CustomUser) + expect(mockTriggerWebhook).toHaveBeenCalledWith({ userId: 'jdoe' }, 'users_edit') + }) + + it('honours an explicit feature key', () => { + triggerUserWebhook({ userId: 'jdoe' } as CustomUser, adminUiFeatures.users_delete) + expect(mockTriggerWebhook).toHaveBeenCalledWith({ userId: 'jdoe' }, 'users_delete') + }) +}) From cee94f5f8bd4d0c13ffe0ca77cc7b15770d29b2f Mon Sep 17 00:00:00 2001 From: faisalsiddique4400 Date: Thu, 2 Jul 2026 19:11:53 +0500 Subject: [PATCH 3/3] feat(admin-ui): add missing test cases for left files in the previous commit (#2915) Signed-off-by: faisalsiddique4400 --- admin-ui/app/constants/__tests__/ui.test.ts | 89 ++++++++++++++ .../app/layout/__tests__/default.test.tsx | 112 ++++++++++++++++++ .../__tests__/jsonPropertiesClient.test.ts | 44 +++++++ .../BuiltIn/__tests__/BuiltIn.test.tsx | 21 ++++ .../services/__tests__/helper/utils.test.ts | 95 ++++++++++++++- 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 admin-ui/app/constants/__tests__/ui.test.ts create mode 100644 admin-ui/app/layout/__tests__/default.test.tsx create mode 100644 admin-ui/plugins/auth-server/api/__tests__/jsonPropertiesClient.test.ts create mode 100644 admin-ui/plugins/auth-server/components/Authentication/BuiltIn/__tests__/BuiltIn.test.tsx diff --git a/admin-ui/app/constants/__tests__/ui.test.ts b/admin-ui/app/constants/__tests__/ui.test.ts new file mode 100644 index 0000000000..d9990e5b79 --- /dev/null +++ b/admin-ui/app/constants/__tests__/ui.test.ts @@ -0,0 +1,89 @@ +import { + OPACITY, + SCROLLBAR, + getHoverOpacity, + getListHoverOpacity, + getDividerOpacity, + getErrorBgOpacity, + getErrorBorderOpacity, + getLoadingOverlayOpacity, + getScrollbarStyles, +} from '@/constants/ui' +import type { ThemeConfig } from '@/context/theme/config' + +// Only the theme-color fields getScrollbarStyles reads are needed here. +const themeColors = { + card: { background: '#111' }, + borderColor: '#222', +} as object as ThemeConfig + +describe('opacity selectors', () => { + it('getHoverOpacity picks the dark/light hover opacity', () => { + expect(getHoverOpacity(true)).toBe(OPACITY.HOVER_DARK) + expect(getHoverOpacity(false)).toBe(OPACITY.HOVER_LIGHT) + }) + + it('getListHoverOpacity picks the dark list-hover / shared light value', () => { + expect(getListHoverOpacity(true)).toBe(OPACITY.LIST_HOVER_DARK) + expect(getListHoverOpacity(false)).toBe(OPACITY.HOVER_DARK) + }) + + it('getDividerOpacity picks the dark/light divider opacity', () => { + expect(getDividerOpacity(true)).toBe(OPACITY.DIVIDER_DARK) + expect(getDividerOpacity(false)).toBe(OPACITY.DIVIDER_LIGHT) + }) + + it('getErrorBgOpacity picks the dark/light error-background opacity', () => { + expect(getErrorBgOpacity(true)).toBe(OPACITY.ERROR_BG_DARK) + expect(getErrorBgOpacity(false)).toBe(OPACITY.ERROR_BG_LIGHT) + }) + + it('getErrorBorderOpacity picks strong for dark and the shared divider value for light', () => { + expect(getErrorBorderOpacity(true)).toBe(OPACITY.STRONG) + expect(getErrorBorderOpacity(false)).toBe(OPACITY.DIVIDER_DARK) + }) + + it('getLoadingOverlayOpacity picks strong for dark and the placeholder value for light', () => { + expect(getLoadingOverlayOpacity(true)).toBe(OPACITY.STRONG) + expect(getLoadingOverlayOpacity(false)).toBe(OPACITY.PLACEHOLDER) + }) + + it('returns numeric opacities in the 0..1 range for every selector', () => { + const selectors = [ + getHoverOpacity, + getListHoverOpacity, + getDividerOpacity, + getErrorBgOpacity, + getErrorBorderOpacity, + getLoadingOverlayOpacity, + ] + for (const select of selectors) { + for (const isDark of [true, false]) { + const value = select(isDark) + expect(typeof value).toBe('number') + expect(value).toBeGreaterThanOrEqual(0) + expect(value).toBeLessThanOrEqual(1) + } + } + }) +}) + +describe('getScrollbarStyles', () => { + it('sizes the scrollbar from the SCROLLBAR constants', () => { + const styles = getScrollbarStyles(themeColors) + expect(styles['&::-webkit-scrollbar']).toEqual({ + width: SCROLLBAR.WIDTH, + height: SCROLLBAR.HEIGHT, + }) + expect(styles['&::-webkit-scrollbar-thumb']).toMatchObject({ + borderRadius: SCROLLBAR.BORDER_RADIUS, + }) + }) + + it('draws the track and thumb from the provided theme colors', () => { + const styles = getScrollbarStyles(themeColors) + expect(styles['&::-webkit-scrollbar-track']).toEqual({ backgroundColor: '#111' }) + expect(styles['&::-webkit-scrollbar-thumb']).toMatchObject({ backgroundColor: '#222' }) + expect(styles['&::-webkit-scrollbar-corner']).toEqual({ backgroundColor: '#111' }) + }) +}) diff --git a/admin-ui/app/layout/__tests__/default.test.tsx b/admin-ui/app/layout/__tests__/default.test.tsx new file mode 100644 index 0000000000..71fc274b5d --- /dev/null +++ b/admin-ui/app/layout/__tests__/default.test.tsx @@ -0,0 +1,112 @@ +import type { PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' +import { DEFAULT_THEME, THEME_LIGHT } from '@/context/theme/constants' +import { STORAGE_KEYS } from '@/constants' + +// getInitialThemeStyle is a module-private helper reached through AppLayout's +// render; ThemeProvider is stubbed to echo the initialStyle it receives so each +// resolution branch can be asserted. Collaborators have their own suites. +type ThemeProviderProps = PropsWithChildren<{ initialStyle?: string; initialColor?: string }> + +jest.mock('Components', () => { + const Layout = ({ children }: PropsWithChildren) =>
{children}
+ Layout.Navbar = ({ children }: PropsWithChildren) =>
{children}
+ Layout.Sidebar = ({ children }: PropsWithChildren) =>
{children}
+ Layout.Content = ({ children }: PropsWithChildren) =>
{children}
+ return { + Layout, + ThemeProvider: ({ children, initialStyle, initialColor }: ThemeProviderProps) => ( +
+ {children} +
+ ), + } +}) + +jest.mock('../../routes', () => ({ + RoutedNavbars: () =>
, + RoutedSidebars: () =>
, +})) + +// SCSS + favicon asset imports are handled by jest asset mocks, but stub the +// stylesheet side-effects and the .ico favicon (not covered by the asset mapper) +// explicitly to keep the module graph small. +jest.mock('Styles/bootstrap.scss', () => ({}), { virtual: true }) +jest.mock('Styles/main.scss', () => ({}), { virtual: true }) +jest.mock('../../images/favicons/favicon.ico', () => 'favicon.ico', { virtual: true }) + +const mockGet = jest.fn() +jest.mock('@/utils/storage', () => ({ storage: { get: (k: string) => mockGet(k) } })) + +const mockLoggerError = jest.fn() +jest.mock('@/utils/logger', () => ({ + logger: { error: (m: string, e?: Error | string) => mockLoggerError(m, e) }, +})) + +import AppLayout from '../default' + +const initialStyle = () => screen.getByTestId('theme-provider').getAttribute('data-style') + +beforeEach(() => { + mockGet.mockReset() + mockLoggerError.mockReset() +}) + +describe('AppLayout', () => { + it('renders the layout shell with navbar and sidebar routes', () => { + mockGet.mockReturnValue(null) + render(page) + expect(screen.getByTestId('layout')).toBeInTheDocument() + expect(screen.getByTestId('navbars')).toBeInTheDocument() + expect(screen.getByTestId('sidebars')).toBeInTheDocument() + }) + + it('renders its children inside the content region', () => { + mockGet.mockReturnValue(null) + render( + + hello + , + ) + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('passes the primary color to the theme provider', () => { + mockGet.mockReturnValue(null) + render(x) + expect(screen.getByTestId('theme-provider')).toHaveAttribute('data-color', 'primary') + }) + + describe('initial theme resolution', () => { + it('uses a saved valid theme from storage', () => { + mockGet.mockReturnValue(THEME_LIGHT) + render(x) + expect(mockGet).toHaveBeenCalledWith(STORAGE_KEYS.INIT_THEME) + expect(initialStyle()).toBe(THEME_LIGHT) + }) + + it('falls back to the default theme when nothing is stored', () => { + mockGet.mockReturnValue(null) + render(x) + expect(initialStyle()).toBe(DEFAULT_THEME) + }) + + it('falls back to the default theme for an invalid stored value', () => { + mockGet.mockReturnValue('not-a-real-theme') + render(x) + expect(initialStyle()).toBe(DEFAULT_THEME) + }) + + it('logs and falls back to the default theme when storage access throws', () => { + mockGet.mockImplementation(() => { + throw new Error('storage blocked') + }) + render(x) + expect(initialStyle()).toBe(DEFAULT_THEME) + expect(mockLoggerError).toHaveBeenCalledWith( + expect.stringContaining('Failed to get initial theme'), + expect.any(Error), + ) + }) + }) +}) diff --git a/admin-ui/plugins/auth-server/api/__tests__/jsonPropertiesClient.test.ts b/admin-ui/plugins/auth-server/api/__tests__/jsonPropertiesClient.test.ts new file mode 100644 index 0000000000..43e9be8260 --- /dev/null +++ b/admin-ui/plugins/auth-server/api/__tests__/jsonPropertiesClient.test.ts @@ -0,0 +1,44 @@ +import type { JsonPatch } from 'JansConfigApi' + +// The generated JansConfigApi client has its own coverage; stub the two calls +// this thin client delegates to and assert it forwards args and returns results. +const mockGetProperties = jest.fn() +const mockPatchProperties = jest.fn() +jest.mock('JansConfigApi', () => ({ + getProperties: () => mockGetProperties(), + patchProperties: (patches: JsonPatch[]) => mockPatchProperties(patches), +})) + +import { callFetchJsonProperties, callPatchJsonProperties } from '../jsonPropertiesClient' + +beforeEach(() => { + mockGetProperties.mockReset() + mockPatchProperties.mockReset() +}) + +describe('callFetchJsonProperties', () => { + it('returns the fetched properties from getProperties', async () => { + mockGetProperties.mockResolvedValue({ issuer: 'https://issuer' }) + await expect(callFetchJsonProperties()).resolves.toEqual({ issuer: 'https://issuer' }) + expect(mockGetProperties).toHaveBeenCalledTimes(1) + }) + + it('propagates a fetch error', async () => { + mockGetProperties.mockRejectedValue(new Error('fetch failed')) + await expect(callFetchJsonProperties()).rejects.toThrow('fetch failed') + }) +}) + +describe('callPatchJsonProperties', () => { + it('forwards the patch payload to patchProperties and returns the result', async () => { + const patches: JsonPatch[] = [{ op: 'replace', path: '/issuer', value: 'x' }] + mockPatchProperties.mockResolvedValue({ issuer: 'x' }) + await expect(callPatchJsonProperties(patches)).resolves.toEqual({ issuer: 'x' }) + expect(mockPatchProperties).toHaveBeenCalledWith(patches) + }) + + it('propagates a patch error', async () => { + mockPatchProperties.mockRejectedValue(new Error('patch failed')) + await expect(callPatchJsonProperties([])).rejects.toThrow('patch failed') + }) +}) diff --git a/admin-ui/plugins/auth-server/components/Authentication/BuiltIn/__tests__/BuiltIn.test.tsx b/admin-ui/plugins/auth-server/components/Authentication/BuiltIn/__tests__/BuiltIn.test.tsx new file mode 100644 index 0000000000..8753e81d1b --- /dev/null +++ b/admin-ui/plugins/auth-server/components/Authentication/BuiltIn/__tests__/BuiltIn.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' + +// Acrs has its own suite; stub it to a marker that echoes the isBuiltIn prop so +// this test asserts BuiltIn wires the built-in filter through. +jest.mock('../../Acrs/Acrs', () => ({ + __esModule: true, + default: ({ isBuiltIn }: { isBuiltIn?: boolean }) => ( +
+ ), +})) + +import BuiltIn from '../BuiltIn' + +describe('BuiltIn', () => { + it('renders the ACR list filtered to built-in methods', () => { + render() + const acrs = screen.getByTestId('acrs') + expect(acrs).toBeInTheDocument() + expect(acrs).toHaveAttribute('data-built-in', 'true') + }) +}) diff --git a/admin-ui/plugins/services/__tests__/helper/utils.test.ts b/admin-ui/plugins/services/__tests__/helper/utils.test.ts index 6b199339d3..bc829aaaa5 100644 --- a/admin-ui/plugins/services/__tests__/helper/utils.test.ts +++ b/admin-ui/plugins/services/__tests__/helper/utils.test.ts @@ -1,4 +1,97 @@ -import { extractActionMessage } from 'Plugins/services/helper/utils' +import type { TFunction } from 'i18next' +import { + extractActionMessage, + isInMemoryCache, + isMemcachedCache, + isRedisCache, + isNativePersistenceCache, + buildCacheChangedFieldOperations, +} from 'Plugins/services/helper/utils' +import type { CacheFormValues } from 'Plugins/services/components/types' + +// Echoes the key so operation paths are asserted without the i18n bundle. +const t = ((key: string) => key) as TFunction + +const cache = (over: Partial): CacheFormValues => + ({ cacheProviderType: 'IN_MEMORY', ...over }) as CacheFormValues + +describe('cache provider type guards', () => { + it('isInMemoryCache is true only for IN_MEMORY', () => { + expect(isInMemoryCache(cache({ cacheProviderType: 'IN_MEMORY' }))).toBe(true) + expect(isInMemoryCache(cache({ cacheProviderType: 'REDIS' }))).toBe(false) + }) + + it('isMemcachedCache is true only for MEMCACHED', () => { + expect(isMemcachedCache(cache({ cacheProviderType: 'MEMCACHED' }))).toBe(true) + expect(isMemcachedCache(cache({ cacheProviderType: 'IN_MEMORY' }))).toBe(false) + }) + + it('isRedisCache is true only for REDIS', () => { + expect(isRedisCache(cache({ cacheProviderType: 'REDIS' }))).toBe(true) + expect(isRedisCache(cache({ cacheProviderType: 'MEMCACHED' }))).toBe(false) + }) + + it('isNativePersistenceCache is true only for NATIVE_PERSISTENCE', () => { + expect(isNativePersistenceCache(cache({ cacheProviderType: 'NATIVE_PERSISTENCE' }))).toBe(true) + expect(isNativePersistenceCache(cache({ cacheProviderType: 'REDIS' }))).toBe(false) + }) +}) + +describe('buildCacheChangedFieldOperations', () => { + it('returns no operations when nothing changed', () => { + const values = cache({ cacheProviderType: 'REDIS', servers: 'localhost:6379' }) + expect(buildCacheChangedFieldOperations(values, { ...values }, t)).toEqual([]) + }) + + it('records a single changed field within the same provider', () => { + const initial = cache({ cacheProviderType: 'REDIS', servers: 'a:6379', maxRetryAttempts: 3 }) + const current = { ...initial, servers: 'b:6379' } + const ops = buildCacheChangedFieldOperations(initial, current, t) + expect(ops).toEqual([{ path: 'fields.servers', value: 'b:6379' }]) + }) + + it('never emits the excluded password field for a same-provider change', () => { + const initial = cache({ cacheProviderType: 'REDIS', password: 'old', servers: 'a' }) + const current = { ...initial, password: 'new' } + const ops = buildCacheChangedFieldOperations(initial, current, t) + // password is explicitly skipped in the field loop. + expect(ops).toEqual([]) + }) + + it('emits a provider-type change and all non-empty fields of the new provider', () => { + const initial = cache({ cacheProviderType: 'IN_MEMORY', memoryDefaultPutExpiration: 60 }) + const current = cache({ + cacheProviderType: 'REDIS', + servers: 'localhost:6379', + maxRetryAttempts: 5, + sentinelMasterGroupName: '', + }) + const ops = buildCacheChangedFieldOperations(initial, current, t) + expect(ops).toContainEqual({ path: 'fields.cache_provider_type', value: 'REDIS' }) + expect(ops).toContainEqual({ path: 'fields.servers', value: 'localhost:6379' }) + expect(ops).toContainEqual({ path: 'fields.max_retry_attempts', value: 5 }) + // Empty-string fields are skipped on a provider change. + expect(ops).not.toContainEqual( + expect.objectContaining({ path: 'fields.sentinel_master_group_name' }), + ) + }) + + it('treats undefined and empty-string values as equal (no spurious op)', () => { + const initial = cache({ cacheProviderType: 'REDIS', servers: undefined }) + const current = cache({ cacheProviderType: 'REDIS', servers: '' }) + expect(buildCacheChangedFieldOperations(initial, current, t)).toEqual([]) + }) + + it('falls back to an empty field list for an unknown provider', () => { + const initial = cache({ cacheProviderType: 'IN_MEMORY' }) + const current = cache({ + cacheProviderType: 'UNKNOWN' as CacheFormValues['cacheProviderType'], + }) + const ops = buildCacheChangedFieldOperations(initial, current, t) + // Only the provider-type change is recorded; no fields exist for the provider. + expect(ops).toEqual([{ path: 'fields.cache_provider_type', value: 'UNKNOWN' }]) + }) +}) describe('extractActionMessage', () => { it('extracts action_message and returns clean data', () => {