From 786bec2842b46a90223c4d7bfd7aa01357460656 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Jul 2026 17:03:32 +1000 Subject: [PATCH] PM-5489: Allow challenge resources to view Work challenges What was broken Problem Writers and Problem Testers added directly to a challenge could not open project-scoped Work challenge URLs when they were not also members of the parent project. Root cause ChallengeEditorPage blocked the challenge fetch and rendered the project access error based only on project membership, so challenge-level resource roles were never evaluated. What was changed Existing challenge routes now fetch the challenge before applying the project fallback. When project access is denied, the page checks the current user's active challenge resource role: full read roles may view the challenge and full write roles may use the edit action. Project/challenge route mismatches still remain denied. Any added/updated tests Updated ChallengeEditorPage tests for Problem Tester-style read access, Problem Writer-style write access, and denial when neither project nor challenge resource access is available. --- .../ChallengeEditorPage.spec.tsx | 133 +++++++++++++++--- .../ChallengeEditorPage.tsx | 124 +++++++++++++--- 2 files changed, 220 insertions(+), 37 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx index b924335c7..b791ed7d2 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx @@ -22,6 +22,7 @@ import { useFetchResources, type UseFetchChallengeResult, } from '../../../lib/hooks' +import type { WorkAppContextModel } from '../../../lib/models' import { deleteChallenge } from '../../../lib/services' import { checkProjectAccess, @@ -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 jest.mock('~/apps/review/src/lib', () => ({ PageWrapper: ( @@ -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({ isAdmin: false, isAnonymous: false, isCopilot: false, @@ -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'], }} > @@ -345,6 +334,7 @@ describe('ChallengeEditorPage', () => { }) mockedCheckProjectAccess.mockReturnValue(true) mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, resourceRoles: [], }) mockedUseFetchResources.mockReturnValue({ @@ -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, @@ -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' })) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx index b1eb06770..404e5af9e 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx @@ -43,6 +43,10 @@ import type { UseFetchChallengeResult, UseFetchProjectResult, } from '../../../lib/hooks' +import type { + Resource, + ResourceRole, +} from '../../../lib/models' import { deleteChallenge, patchChallenge, @@ -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. * @@ -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((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. * @@ -1085,21 +1152,7 @@ export const ChallengeEditorPage: FC = () => { const [showLaunchModal, setShowLaunchModal] = useState(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, @@ -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, @@ -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, @@ -1355,6 +1446,7 @@ export const ChallengeEditorPage: FC = () => { && canRenderChallengeDetails && isViewMode && !!editChallengePath + && (!baseProjectAccessState.isDenied || challengeResourceAccess.canWrite) && !isChallengeCompletedOrCancelled(effectiveChallengeStatus) const rightHeader = renderHeaderAction({ canCancelChallenge: canRenderChallengeDetails && canCancelChallenge,