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
Expand Up @@ -22,6 +22,7 @@ import {
useFetchResources,
type UseFetchChallengeResult,
} from '../../../lib/hooks'
import type { WorkAppContextModel } from '../../../lib/models'
import { deleteChallenge } from '../../../lib/services'
import {
checkProjectAccess,
Expand All @@ -34,15 +35,7 @@ import {

import { ChallengeEditorPage } from './ChallengeEditorPage'

var mockWorkAppContext: Context<{
isAdmin: boolean
isAnonymous: boolean
isCopilot: boolean
isManager: boolean
isReadOnly: boolean
loginUserInfo: undefined
userRoles: string[]
}>
var mockWorkAppContext: Context<WorkAppContextModel>

jest.mock('~/apps/review/src/lib', () => ({
PageWrapper: (
Expand Down Expand Up @@ -142,15 +135,7 @@ jest.mock('../../../lib/constants', () => ({
jest.mock('../../../lib/contexts', () => {
const React = require('react') as typeof import('react')

mockWorkAppContext = React.createContext<{
isAdmin: boolean
isAnonymous: boolean
isCopilot: boolean
isManager: boolean
isReadOnly: boolean
loginUserInfo: undefined
userRoles: string[]
}>({
mockWorkAppContext = React.createContext<WorkAppContextModel>({
isAdmin: false,
isAnonymous: false,
isCopilot: false,
Expand Down Expand Up @@ -288,7 +273,11 @@ function renderPageElement(route: string, path: string): JSX.Element {
isCopilot: false,
isManager: true,
isReadOnly: false,
loginUserInfo: undefined,
loginUserInfo: {
handle: 'current-user',
roles: ['manager'],
userId: 12345,
},
userRoles: ['manager'],
}}
>
Expand Down Expand Up @@ -345,6 +334,7 @@ describe('ChallengeEditorPage', () => {
})
mockedCheckProjectAccess.mockReturnValue(true)
mockedUseFetchResourceRoles.mockReturnValue({
isLoading: false,
resourceRoles: [],
})
mockedUseFetchResources.mockReturnValue({
Expand Down Expand Up @@ -552,7 +542,108 @@ describe('ChallengeEditorPage', () => {
.toBe(false)
})

it('blocks project-scoped challenge views when project access is denied', async () => {
it('allows project-scoped challenge views when the user has challenge resource read access', async () => {
mockedCheckProjectAccess.mockReturnValue(false)
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
project: {
id: '123',
members: [{
userId: 99999,
}],
name: 'Restricted Project',
status: 'active',
},
})
mockedUseFetchResourceRoles.mockReturnValue({
isLoading: false,
resourceRoles: [{
fullReadAccess: true,
fullWriteAccess: false,
id: 'problem-tester-role',
isActive: true,
name: 'Problem Tester',
}],
})
mockedUseFetchResources.mockReturnValue({
isLoading: false,
resources: [{
challengeId: '456',
memberId: '12345',
roleId: 'problem-tester-role',
}],
})

renderPage(
'/projects/123/challenges/456/view',
'/projects/:projectId/challenges/:challengeId/view',
)

await waitFor(() => {
expect(screen.getByText('Challenge View Form'))
.toBeTruthy()
})

expect(mockedUseFetchChallenge)
.toHaveBeenCalledWith('456')
expect(screen.queryByText('You don’t have access to this project. Please contact support@topcoder.com.'))
.toBeNull()
expect(screen.queryByRole('button', { name: 'Edit' }))
.toBeNull()
})

it(
'shows edit action for project-scoped challenge views when the challenge resource grants write access',
async () => {
mockedCheckProjectAccess.mockReturnValue(false)
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
project: {
id: '123',
members: [{
userId: 99999,
}],
name: 'Restricted Project',
status: 'active',
},
})
mockedUseFetchResourceRoles.mockReturnValue({
isLoading: false,
resourceRoles: [{
fullReadAccess: true,
fullWriteAccess: true,
id: 'problem-writer-role',
isActive: true,
name: 'Problem Writer',
}],
})
mockedUseFetchResources.mockReturnValue({
isLoading: false,
resources: [{
challengeId: '456',
memberId: '12345',
roleId: 'problem-writer-role',
}],
})

renderPage(
'/projects/123/challenges/456/view',
'/projects/:projectId/challenges/:challengeId/view',
)

await waitFor(() => {
expect(screen.getByText('Challenge View Form'))
.toBeTruthy()
})

expect(screen.getByRole('button', { name: 'Edit' }))
.toBeTruthy()
},
)

it('blocks project-scoped challenge views when project and challenge resource access are denied', async () => {
mockedCheckProjectAccess.mockReturnValue(false)
mockedUseFetchProject.mockReturnValue({
error: undefined,
Expand All @@ -578,7 +669,7 @@ describe('ChallengeEditorPage', () => {
})

expect(mockedUseFetchChallenge)
.toHaveBeenCalledWith(undefined)
.toHaveBeenCalledWith('456')
expect(screen.queryByText('Challenge View Form'))
.toBeNull()
expect(screen.queryByRole('heading', { name: 'View Edit test' }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ import type {
UseFetchChallengeResult,
UseFetchProjectResult,
} from '../../../lib/hooks'
import type {
Resource,
ResourceRole,
} from '../../../lib/models'
import {
deleteChallenge,
patchChallenge,
Expand Down Expand Up @@ -168,6 +172,11 @@ interface ChallengeProjectAccessState {
isLoading: boolean
}

interface ChallengeResourceAccessState {
canRead: boolean
canWrite: boolean
}

/**
* Normalizes a project identifier from route, challenge, or created challenge data.
*
Expand All @@ -185,6 +194,64 @@ function normalizeProjectId(projectId: number | string | undefined): string | un
return normalizedProjectId || undefined
}

/**
* Normalizes a user identifier for challenge resource membership comparisons.
*
* @param userId user id from auth context or a resource API payload.
* @returns trimmed user id text when present; otherwise an empty string.
* @throws Does not throw.
*/
function normalizeUserId(userId: number | string | undefined): string {
if (userId === undefined || userId === null) {
return ''
}

return String(userId)
.trim()
}

/**
* Resolves challenge-level read and write access from the current user's resource roles.
*
* @param resources resources assigned on the challenge.
* @param resourceRoles resource role metadata, including read and write flags.
* @param userId current user id from the work app context.
* @returns read/write access flags granted by active challenge resource roles.
* @throws Does not throw.
*/
function resolveChallengeResourceAccess(
resources: Resource[],
resourceRoles: ResourceRole[],
userId: number | string | undefined,
): ChallengeResourceAccessState {
const normalizedUserId = normalizeUserId(userId)

if (!normalizedUserId) {
return {
canRead: false,
canWrite: false,
}
}

const userResourceRoleIds = new Set(resources
.filter(resource => normalizeUserId(resource.memberId) === normalizedUserId)
.map(resource => resource.roleId))

return resourceRoles.reduce<ChallengeResourceAccessState>((accessState, resourceRole) => {
if (!userResourceRoleIds.has(resourceRole.id) || resourceRole.isActive === false) {
return accessState
}

return {
canRead: accessState.canRead || resourceRole.fullReadAccess === true,
canWrite: accessState.canWrite || resourceRole.fullWriteAccess === true,
}
}, {
canRead: false,
canWrite: false,
})
}

/**
* Resolves whether challenge editor content can render for a project-owned challenge.
*
Expand Down Expand Up @@ -1085,21 +1152,7 @@ export const ChallengeEditorPage: FC = () => {
const [showLaunchModal, setShowLaunchModal] = useState<boolean>(false)
const workAppContext = useContext(WorkAppContext)
const routeProjectResult: UseFetchProjectResult = useFetchProject(routeProjectId)
const canFetchProjectRouteChallenge = !routeProjectId
|| (
!routeProjectResult.isLoading
&& !routeProjectResult.error
&& checkProjectAccess(
workAppContext.userRoles,
workAppContext.loginUserInfo?.userId,
routeProjectResult.project,
)
)
const challengeResult: UseFetchChallengeResult = useFetchChallenge(
canFetchProjectRouteChallenge
? challengeId
: undefined,
)
const challengeResult: UseFetchChallengeResult = useFetchChallenge(challengeId)
const [
challengeStatus,
handleChallengeStatusChange,
Expand Down Expand Up @@ -1329,7 +1382,7 @@ export const ChallengeEditorPage: FC = () => {
const hasChallengeProjectMismatch = !!routeProjectId
&& !!challengeProjectId
&& routeProjectId !== challengeProjectId
const projectAccessState = resolveChallengeProjectAccess({
const baseProjectAccessState = resolveChallengeProjectAccess({
hasChallengeProjectMismatch,
isProjectLoading: projectAccessResult.isLoading,
project: projectAccessResult.project,
Expand All @@ -1338,6 +1391,44 @@ export const ChallengeEditorPage: FC = () => {
userId: workAppContext.loginUserInfo?.userId,
userRoles: workAppContext.userRoles,
})
const shouldResolveChallengeResourceAccess = isExistingChallenge
&& baseProjectAccessState.isDenied
&& !hasChallengeProjectMismatch
&& !!challengeId
const shouldFetchChallengeResources = shouldResolveChallengeResourceAccess
&& !!challengeResult.challenge
const resourcesResult = useFetchResources(
shouldFetchChallengeResources
? challengeId
: undefined,
)
const resourceRolesResult = useFetchResourceRoles()
const challengeResourceAccess = resolveChallengeResourceAccess(
resourcesResult.resources,
resourceRolesResult.resourceRoles,
workAppContext.loginUserInfo?.userId,
)
const hasChallengeResourceRouteAccess = isViewMode
? challengeResourceAccess.canRead
: challengeResourceAccess.canWrite
const isChallengeResourceAccessLoading = shouldResolveChallengeResourceAccess
&& (
challengeResult.isLoading
|| (
!!challengeResult.challenge
&& (
resourcesResult.isLoading
|| resourceRolesResult.isLoading
)
)
)
const projectAccessState = {
isDenied: baseProjectAccessState.isDenied
&& !hasChallengeResourceRouteAccess
&& !isChallengeResourceAccessLoading,
isLoading: baseProjectAccessState.isLoading
|| isChallengeResourceAccessLoading,
}
const canRenderChallengeDetails = !projectAccessState.isDenied && !projectAccessState.isLoading
const pageTitle = getChallengeEditorPageTitle(
challengeId,
Expand All @@ -1355,6 +1446,7 @@ export const ChallengeEditorPage: FC = () => {
&& canRenderChallengeDetails
&& isViewMode
&& !!editChallengePath
&& (!baseProjectAccessState.isDenied || challengeResourceAccess.canWrite)
&& !isChallengeCompletedOrCancelled(effectiveChallengeStatus)
const rightHeader = renderHeaderAction({
canCancelChallenge: canRenderChallengeDetails && canCancelChallenge,
Expand Down
Loading