Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<AccordionContextType>, ui: React.ReactNode) =>
render(
<AccordionContext.Provider value={{ isOpen: false, onToggle: () => {}, ...ctx }}>
{ui}
</AccordionContext.Provider>,
)

describe('AccordionIndicator', () => {
it('renders the closed indicator (add icon) when collapsed', () => {
const { container } = withContext(
{ isOpen: false },
<AccordionIndicator
open={<span data-testid="open-icon">open</span>}
closed={<span data-testid="closed-icon">closed</span>}
/>,
)
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 },
<AccordionIndicator
open={<span data-testid="open-icon">open</span>}
closed={<span data-testid="closed-icon">closed</span>}
/>,
)
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 },
<AccordionIndicator className="extra" open={<span className="own">x</span>} />,
)
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 }, <AccordionBody className="mine">body content</AccordionBody>)
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 }, <AccordionHeader>header</AccordionHeader>)
fireEvent.click(screen.getByText('header'))
expect(onToggle).toHaveBeenCalledTimes(1)
})
})
80 changes: 80 additions & 0 deletions admin-ui/app/components/App/__tests__/AppMain.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }>) => (
<div data-testid="router">{children}</div>
),
}))

jest.mock('../AuthenticatedRouteSelector', () => ({
__esModule: true,
default: () => <div data-testid="route-selector">routes</div>,
}))

jest.mock('Routes/Apps/Gluu/GluuErrorScreen', () => ({
__esModule: true,
default: () => <div data-testid="error-screen">error</div>,
}))

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 <div data-testid="error-boundary">{props.children}</div>
},
}))

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(<AppMain />)
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(<AppMain />)
const Fallback = capturedProps?.FallbackComponent as React.ComponentType
render(<Fallback />)
expect(screen.getByTestId('error-screen')).toBeInTheDocument()
})

it('logs a thrown Error through logUiCrash with the component stack', () => {
render(<AppMain />)
const err = new Error('boom')
capturedProps?.onError(err, { componentStack: 'at <X>' })
expect(mockLogUiCrash).toHaveBeenCalledWith(err, 'at <X>')
})

it('coerces a non-Error value into an Error before logging', () => {
render(<AppMain />)
capturedProps?.onError('string failure', { componentStack: 'at <Y>' })
const [loggedError, stack] = mockLogUiCrash.mock.calls[0]
expect(loggedError).toBeInstanceOf(Error)
expect((loggedError as Error).message).toBe('string failure')
expect(stack).toBe('at <Y>')
})
})
Original file line number Diff line number Diff line change
@@ -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 ? <div data-testid={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) => <div data-testid="layout">{children}</div>,
}))
jest.mock('../../../routes/index', () => ({
RoutedContent: () => <div data-testid="routed-content" />,
}))
jest.mock('Utils/AppAuthProvider', () => ({
__esModule: true,
default: ({ children }: PropsWithChildren) => <div data-testid="auth-provider">{children}</div>,
}))
jest.mock('../PermissionsPolicyInitializer', () => ({
__esModule: true,
default: () => <div data-testid="perms-init" />,
}))

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(<AuthenticatedRouteSelector />)
expect(screen.getByTestId('byebye')).toBeInTheDocument()
expect(screen.queryByTestId('routed-content')).not.toBeInTheDocument()
})

it('renders the authenticated app shell on a normal route', () => {
render(<AuthenticatedRouteSelector />)
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(<AuthenticatedRouteSelector />)
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(<AuthenticatedRouteSelector />)
expect(mockPreloads.DashboardPage).toHaveBeenCalled()
expect(mockPreloads.ProfilePage).not.toHaveBeenCalled()
})

it('preloads the dashboard on the home-dashboard route', () => {
mockLocation.pathname = '/home/dashboard'
render(<AuthenticatedRouteSelector />)
expect(mockPreloads.DashboardPage).toHaveBeenCalled()
})

it('preloads the profile page on the profile route', () => {
mockLocation.pathname = '/profile'
render(<AuthenticatedRouteSelector />)
expect(mockPreloads.ProfilePage).toHaveBeenCalled()
expect(mockPreloads.DashboardPage).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -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: <T,>(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(<PermissionsPolicyInitializer />)
expect(container).toBeEmptyDOMElement()
})

it('initializes cedarling when all gating conditions are met', async () => {
mockInitialize.mockResolvedValue(undefined)
render(<PermissionsPolicyInitializer />)
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(<PermissionsPolicyInitializer />)
expect(mockInitialize).not.toHaveBeenCalled()
})

it('does not initialize when already initialized', () => {
mockState.cedarPermissions.initialized = true as never
render(<PermissionsPolicyInitializer />)
expect(mockInitialize).not.toHaveBeenCalled()
})

it('does not initialize when policy store bytes are blank', () => {
mockState.cedarPermissions.policyStoreBytes = ' ' as never
render(<PermissionsPolicyInitializer />)
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(<PermissionsPolicyInitializer />)
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(<PermissionsPolicyInitializer />)
await waitFor(() => expect(mockInitialize).toHaveBeenCalled())
jest.advanceTimersByTime(1000)
await waitFor(() =>
expect(mockDispatch).toHaveBeenCalledWith({ type: 'initialized', payload: false }),
)
jest.useRealTimers()
})
})
Loading
Loading