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,219 @@
/* eslint-disable no-var, global-require, @typescript-eslint/no-var-requires */
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import type {
Context,
PropsWithChildren,
} from 'react'
import {
render,
screen,
} from '@testing-library/react'
import {
MemoryRouter,
Route,
Routes,
} from 'react-router-dom'

import { WorkAppContextModel } from '../../models'
import { useFetchProject } from '../../hooks'
import { checkProjectAccess } from '../../utils'

import {
PROJECT_ACCESS_DENIED_MESSAGE,
ProjectRouteAccessGuard,
} from './ProjectRouteAccessGuard'

var mockWorkAppContext: Context<WorkAppContextModel>

jest.mock('~/apps/review/src/lib', () => ({
PageWrapper: (
props: PropsWithChildren<{
pageTitle?: string
}>,
) => (
<div>
<h1>{props.pageTitle}</h1>
<div data-testid='page-content'>{props.children}</div>
</div>
),
}), {
virtual: true,
})
jest.mock('~/libs/ui', () => ({
Button: (props: { label: string }) => (
<button type='button'>{props.label}</button>
),
LoadingSpinner: () => <div>Loading Spinner</div>,
}), {
virtual: true,
})
jest.mock('../../contexts', () => {
const React = require('react') as typeof import('react')

mockWorkAppContext = React.createContext<WorkAppContextModel>({
isAdmin: false,
isAnonymous: false,
isCopilot: false,
isManager: false,
isReadOnly: false,
loginUserInfo: undefined,
userRoles: [],
})

return {
WorkAppContext: mockWorkAppContext,
}
})
jest.mock('../../hooks', () => ({
useFetchProject: jest.fn(),
}))
jest.mock('../../utils', () => ({
checkProjectAccess: jest.fn(),
}))

const mockedUseFetchProject = useFetchProject as jest.Mock
const mockedCheckProjectAccess = checkProjectAccess as jest.Mock

const defaultContextValue: WorkAppContextModel = {
isAdmin: false,
isAnonymous: false,
isCopilot: false,
isManager: true,
isReadOnly: false,
loginUserInfo: {
email: 'manager@example.com',
exp: 0,
handle: 'manager-user',
iat: 0,
roles: ['Project Manager'],
userId: 12345,
} as WorkAppContextModel['loginUserInfo'],
userRoles: ['Project Manager'],
}

function renderGuard(
route: string,
contextValue: WorkAppContextModel = defaultContextValue,
): void {
const MockWorkAppContext = mockWorkAppContext

render(
<MockWorkAppContext.Provider value={contextValue}>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route
path='/projects/:projectId/users'
element={(
<ProjectRouteAccessGuard pageTitle='Users'>
<div>Protected Project Users</div>
</ProjectRouteAccessGuard>
)}
/>
</Routes>
</MemoryRouter>
</MockWorkAppContext.Provider>,
)
}

describe('ProjectRouteAccessGuard', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
mutate: jest.fn(),
project: {
id: 200,
members: [{
userId: 12345,
}],
},
})
mockedCheckProjectAccess.mockReturnValue(true)
})

it('renders the protected route when project access is allowed', () => {
renderGuard('/projects/200/users')

expect(mockedCheckProjectAccess)
.toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 }))
expect(screen.getByText('Protected Project Users'))
.toBeTruthy()
})

it('renders the protected route when cached project access survives a revalidation error', () => {
mockedUseFetchProject.mockReturnValue({
error: new Error('Network unavailable'),
isLoading: false,
mutate: jest.fn(),
project: {
id: 200,
members: [{
userId: 12345,
}],
},
})
mockedCheckProjectAccess.mockReturnValue(true)

renderGuard('/projects/200/users')

expect(mockedCheckProjectAccess)
.toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 }))
expect(screen.getByText('Protected Project Users'))
.toBeTruthy()
expect(screen.queryByText(PROJECT_ACCESS_DENIED_MESSAGE))
.toBeNull()
})

it('shows loading while project access is resolving', () => {
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: true,
mutate: jest.fn(),
project: undefined,
})

renderGuard('/projects/200/users')

expect(screen.getByRole('heading', { level: 1, name: 'Users' }))
.toBeTruthy()
expect(screen.getByText('Loading Spinner'))
.toBeTruthy()
expect(screen.queryByText('Protected Project Users'))
.toBeNull()
expect(mockedCheckProjectAccess)
.not.toHaveBeenCalled()
})

it('shows the project access denial message when project access is rejected', () => {
mockedCheckProjectAccess.mockReturnValue(false)

renderGuard('/projects/200/users')

expect(screen.getByRole('heading', { level: 1, name: 'Users' }))
.toBeTruthy()
expect(screen.getByText(PROJECT_ACCESS_DENIED_MESSAGE))
.toBeTruthy()
expect(screen.queryByText('Protected Project Users'))
.toBeNull()
})

it('shows the project access denial message when the project fetch fails', () => {
mockedUseFetchProject.mockReturnValue({
error: new Error('Forbidden'),
isLoading: false,
mutate: jest.fn(),
project: undefined,
})
mockedCheckProjectAccess.mockReturnValue(false)

renderGuard('/projects/200/users')

expect(mockedCheckProjectAccess)
.toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, undefined)
expect(screen.getByText(PROJECT_ACCESS_DENIED_MESSAGE))
.toBeTruthy()
expect(screen.queryByText('Protected Project Users'))
.toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
FC,
PropsWithChildren,
useContext,
} from 'react'
import { useParams } from 'react-router-dom'

import { PageWrapper } from '~/apps/review/src/lib'

import { WorkAppContext } from '../../contexts'
import { useFetchProject } from '../../hooks'
import { WorkAppContextModel } from '../../models'
import { checkProjectAccess } from '../../utils'
import { ErrorMessage } from '../ErrorMessage'
import { LoadingSpinner } from '../LoadingSpinner'

export const PROJECT_ACCESS_DENIED_MESSAGE
= 'You don’t have access to this project. Please contact support@topcoder.com.'

interface ProjectRouteAccessGuardProps extends PropsWithChildren {
pageTitle: string
}

/**
* Blocks project-scoped Work routes until the current user has project access.
*
* @param props child route content and fallback page title used while access is loading or denied.
* @returns child route content when the project exists and the caller is an admin or project member.
* @remarks Used by project workspace routes so unauthorized users do not mount pages that fetch project child data.
* Access decisions use cached project data when available, so SWR revalidation errors do not block authorized users.
* @throws Does not throw; missing project access renders the standard project access denial message.
*/
export const ProjectRouteAccessGuard: FC<ProjectRouteAccessGuardProps> = (
props: ProjectRouteAccessGuardProps,
) => {
const params: Readonly<{ projectId?: string }> = useParams<'projectId'>()
const projectId = params.projectId?.trim()

const workAppContext = useContext(WorkAppContext) as WorkAppContextModel
const projectResult = useFetchProject(projectId || undefined)

if (!projectId) {
return <>{props.children}</>
}

if (projectResult.isLoading) {
return (
<PageWrapper
breadCrumb={[]}
pageTitle={props.pageTitle}
>
<LoadingSpinner />
</PageWrapper>
)
}

const hasProjectAccess = checkProjectAccess(
workAppContext.userRoles,
workAppContext.loginUserInfo?.userId,
projectResult.project,
)

if (!hasProjectAccess) {
return (
<PageWrapper
breadCrumb={[]}
pageTitle={props.pageTitle}
>
<ErrorMessage message={PROJECT_ACCESS_DENIED_MESSAGE} />
</PageWrapper>
)
}

return <>{props.children}</>
}

export default ProjectRouteAccessGuard
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ProjectRouteAccessGuard'
1 change: 1 addition & 0 deletions src/apps/work/src/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export * from './Pagination'
export * from './ProjectCard'
export * from './ProjectBillingAccountExpiredNotice'
export * from './ProjectListTabs'
export * from './ProjectRouteAccessGuard'
export * from './ProjectStatus'
export * from './PaymentFormModal'
export * from './PaymentHistoryModal'
Expand Down
Loading
Loading