diff --git a/.circleci/config.yml b/.circleci/config.yml index d348291b..1ee64edd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -160,7 +160,7 @@ workflows: context: org-global filters: &filters-dev branches: - only: ["develop", "pm-2917", "points", "pm-3270", "projects-api-v6"] + only: ["develop", "pm-2917", "points", "pm-3270", "permissions-hotfix"] # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/config/constants/local.js b/config/constants/local.js index c551b163..e90792c2 100644 --- a/config/constants/local.js +++ b/config/constants/local.js @@ -47,7 +47,6 @@ module.exports = { // Copilots and other apps remain on dev COPILOTS_URL: 'https://copilots.topcoder-dev.com', - // Projects API v6: keep dev default (switch to LOCAL_PROJECTS_API when needed) PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`, // Local groups/resources/review services diff --git a/src/components/ChallengesComponent/ChallengeList/index.js b/src/components/ChallengesComponent/ChallengeList/index.js index 8de51f21..5866bcf4 100644 --- a/src/components/ChallengesComponent/ChallengeList/index.js +++ b/src/components/ChallengesComponent/ChallengeList/index.js @@ -25,7 +25,7 @@ import Loader from '../../Loader' import UpdateBillingAccount from '../../UpdateBillingAccount' import { CHALLENGE_STATUS, PAGE_SIZE, PAGINATION_PER_PAGE_OPTIONS, PROJECT_ROLES } from '../../../config/constants' -import { checkAdmin, checkManager, checkReadOnlyRoles } from '../../../util/tc' +import { checkAdmin, checkCanManageProjectBillingAccount, checkReadOnlyRoles } from '../../../util/tc' require('bootstrap/scss/bootstrap.scss') @@ -405,10 +405,10 @@ class ChallengeList extends Component { fetchNextProjects } = this.props const isReadOnly = checkReadOnlyRoles(this.props.auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ - const isAdmin = checkAdmin(this.props.auth.token) - const isManager = checkManager(this.props.auth.token) - const loginUserId = this.props.auth.user.userId - const isMemberOfActiveProject = activeProject && activeProject.members && activeProject.members.some(member => member.userId === loginUserId) + const canManageBillingAccount = checkCanManageProjectBillingAccount( + this.props.auth.token, + activeProject + ) if (warnMessage) { return @@ -495,12 +495,10 @@ class ChallengeList extends Component { billingStartDate={billingStartDate} billingEndDate={billingEndDate} isBillingAccountExpired={isBillingAccountExpired} - isAdmin={isAdmin} + canManageBillingAccount={canManageBillingAccount} currentBillingAccount={currentBillingAccount} updateProject={updateProject} projectId={activeProject.id} - isMemberOfActiveProject={isMemberOfActiveProject} - isManager={isManager} /> ) : ( diff --git a/src/components/ChallengesComponent/index.js b/src/components/ChallengesComponent/index.js index dd85ac1c..dc4e4ea9 100644 --- a/src/components/ChallengesComponent/index.js +++ b/src/components/ChallengesComponent/index.js @@ -2,7 +2,6 @@ * Component to render Challenges page */ import React, { useState, useEffect } from 'react' -import _ from 'lodash' import PropTypes from 'prop-types' import { Helmet } from 'react-helmet' import { Link } from 'react-router-dom' @@ -11,14 +10,7 @@ import { PROJECT_ROLES, PROJECT_STATUS, COPILOTS_URL, CHALLENGE_STATUS } from '. import { PrimaryButton, OutlineButton } from '../Buttons' import ChallengeList from './ChallengeList' import styles from './ChallengesComponent.module.scss' -import { - checkAdmin, - checkReadOnlyRoles, - checkAdminOrCopilotOrManager, - checkCanViewProjectAssets, - checkManager, - getProjectMemberByUserId -} from '../../util/tc' +import { checkAdmin, checkCanManageProject, checkCanViewProjectAssets, checkReadOnlyRoles, checkManager, getProjectMemberRole } from '../../util/tc' const ChallengesComponent = ({ challenges, @@ -58,8 +50,8 @@ const ChallengesComponent = ({ }) => { const [loginUserRoleInProject, setLoginUserRoleInProject] = useState('') const isReadOnly = checkReadOnlyRoles(auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ + const canManageProject = checkCanManageProject(auth.token, activeProject) const canViewAssets = checkCanViewProjectAssets(auth.token, activeProject) - const canEditProject = checkAdminOrCopilotOrManager(auth.token, activeProject) const projectStatus = activeProject && activeProject.status ? activeProject.status.toUpperCase() @@ -69,9 +61,9 @@ const ChallengesComponent = ({ useEffect(() => { const loggedInUser = auth.user - const loginUserProjectInfo = getProjectMemberByUserId(activeProject, _.get(loggedInUser, 'userId')) - if (loginUserProjectInfo && loginUserRoleInProject !== loginUserProjectInfo.role) { - setLoginUserRoleInProject(loginUserProjectInfo.role) + const loginUserProjectRole = getProjectMemberRole(activeProject, loggedInUser.userId) + if (loginUserProjectRole && loginUserRoleInProject !== loginUserProjectRole) { + setLoginUserRoleInProject(loginUserProjectRole) } }, [activeProject, auth, loginUserRoleInProject]) @@ -84,7 +76,7 @@ const ChallengesComponent = ({ {activeProject ? activeProject.name : ''} {activeProject && activeProject.status && } - {activeProject && activeProject.id && canEditProject && ( + {activeProject && activeProject.id && canManageProject && ( ( ({ jest.mock('../../util/tc', () => ({ checkAdmin: jest.fn(), checkReadOnlyRoles: jest.fn(), - checkAdminOrCopilotOrManager: jest.fn(), + checkCanManageProject: jest.fn(), checkCanViewProjectAssets: jest.fn(), checkManager: jest.fn(), - getProjectMemberByUserId: jest.fn((project, userId) => { + getProjectMemberRole: jest.fn((project, userId) => { const members = (project && project.members) || [] - return members.find(member => `${member.userId}` === `${userId}`) || null + const member = members.find(candidate => `${candidate.userId}` === `${userId}`) || null + return member ? member.role : null }) })) @@ -111,7 +112,7 @@ describe('ChallengesComponent', () => { tcUtils.checkAdmin.mockReturnValue(false) tcUtils.checkReadOnlyRoles.mockReturnValue(false) - tcUtils.checkAdminOrCopilotOrManager.mockReturnValue(false) + tcUtils.checkCanManageProject.mockReturnValue(false) tcUtils.checkCanViewProjectAssets.mockReturnValue(true) tcUtils.checkManager.mockReturnValue(false) }) diff --git a/src/components/UpdateBillingAccount/index.js b/src/components/UpdateBillingAccount/index.js index 71cca494..86a7115e 100644 --- a/src/components/UpdateBillingAccount/index.js +++ b/src/components/UpdateBillingAccount/index.js @@ -14,12 +14,10 @@ const UpdateBillingAccount = ({ billingStartDate, billingEndDate, isBillingAccountExpired, - isAdmin, + canManageBillingAccount, currentBillingAccount, projectId, - updateProject, - isMemberOfActiveProject, - isManager + updateProject }) => { const [isEditing, setIsEditing] = useState(false) const [selectedBillingAccount, setSelectedBillingAccount] = useState(null) @@ -131,7 +129,7 @@ const UpdateBillingAccount = ({ !currentBillingAccount && ( No Billing Account set - {(isAdmin || (isManager && isMemberOfActiveProject)) && ( + {canManageBillingAccount && ( {' '} ({' '} @@ -155,7 +153,7 @@ const UpdateBillingAccount = ({ > {isBillingAccountExpired ? 'INACTIVE' : 'ACTIVE'} {' '} - {(isAdmin || (isManager && isMemberOfActiveProject)) && ( + {canManageBillingAccount && ( {' '} ({' '} @@ -187,11 +185,9 @@ UpdateBillingAccount.propTypes = { billingEndDate: PropTypes.string, currentBillingAccount: PropTypes.number, isBillingAccountExpired: PropTypes.bool, - isAdmin: PropTypes.bool, + canManageBillingAccount: PropTypes.bool, projectId: PropTypes.number, - updateProject: PropTypes.func.isRequired, - isMemberOfActiveProject: PropTypes.bool.isRequired, - isManager: PropTypes.bool.isRequired + updateProject: PropTypes.func.isRequired } export default UpdateBillingAccount diff --git a/src/components/UpdateBillingAccount/index.test.js b/src/components/UpdateBillingAccount/index.test.js new file mode 100644 index 00000000..02b9145d --- /dev/null +++ b/src/components/UpdateBillingAccount/index.test.js @@ -0,0 +1,90 @@ +/* global describe, it, expect, beforeEach, afterEach, jest */ + +import React from 'react' +import ReactDOM from 'react-dom' +import { act } from 'react-dom/test-utils' +import UpdateBillingAccount from './index' + +jest.mock('../Select', () => () => null) +jest.mock('../Buttons', () => { + const React = require('react') + + const renderButton = (text) => React.createElement( + 'button', + { type: 'button' }, + text + ) + + return { + PrimaryButton: ({ text }) => renderButton(text), + OutlineButton: ({ text }) => renderButton(text) + } +}) + +describe('UpdateBillingAccount', () => { + let container + + const defaultProps = { + billingAccounts: [], + isBillingAccountsLoading: false, + isBillingAccountLoading: false, + isBillingAccountLoadingFailed: false, + billingStartDate: 'Jan 01, 2026', + billingEndDate: 'Dec 31, 2026', + isBillingAccountExpired: false, + canManageBillingAccount: false, + currentBillingAccount: null, + projectId: 1001, + updateProject: () => {} + } + + const renderComponent = (props = {}) => { + act(() => { + ReactDOM.render( + , + container + ) + }) + } + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + ReactDOM.unmountComponentAtNode(container) + container.remove() + container = null + }) + + it('shows the select action when the user can manage billing accounts and none is assigned', () => { + renderComponent({ + canManageBillingAccount: true, + isBillingAccountLoadingFailed: true + }) + + expect(container.textContent).toContain('No Billing Account set') + expect(container.textContent).toContain('Select Billing Account') + }) + + it('shows the edit action when the user can manage an assigned billing account', () => { + renderComponent({ + canManageBillingAccount: true, + currentBillingAccount: 12345 + }) + + expect(container.textContent).toContain('Billing Account:') + expect(container.textContent).toContain('Edit Billing Account') + }) + + it('hides management actions when the user cannot manage billing accounts', () => { + renderComponent({ + currentBillingAccount: 12345 + }) + + expect(container.textContent).toContain('Billing Account:') + expect(container.textContent).not.toContain('Edit Billing Account') + expect(container.textContent).not.toContain('Select Billing Account') + }) +}) diff --git a/src/containers/Challenges/helper.js b/src/containers/Challenges/helper.js new file mode 100644 index 00000000..79f2efd8 --- /dev/null +++ b/src/containers/Challenges/helper.js @@ -0,0 +1,26 @@ +/** + * Returns project details only when they match the current project context. + * + * This prevents stale project data from rendering on another project page while + * still handling APIs that may return ids as strings. + * + * @param {Object} projectDetail Loaded project details from redux. + * @param {string|number} projectId Project id from the current route. + * @param {number} activeProjectId Active project id stored in the sidebar state. + * @returns {Object} The matching project detail object, or an empty object. + */ +export function getActiveProject (projectDetail, projectId, activeProjectId) { + if (!projectDetail) { + return {} + } + + const scopedProjectId = projectId != null + ? `${projectId}` + : (activeProjectId != null && activeProjectId !== -1 ? `${activeProjectId}` : '') + + if (!scopedProjectId || `${projectDetail.id}` !== scopedProjectId) { + return {} + } + + return projectDetail +} diff --git a/src/containers/Challenges/helper.test.js b/src/containers/Challenges/helper.test.js new file mode 100644 index 00000000..12e36dc3 --- /dev/null +++ b/src/containers/Challenges/helper.test.js @@ -0,0 +1,23 @@ +/* global describe, it, expect */ + +import { getActiveProject } from './helper' + +describe('getActiveProject', () => { + it('returns the project detail when the route project id matches a string project id', () => { + const projectDetail = { + id: '100566', + name: 'Project Phoenix' + } + + expect(getActiveProject(projectDetail, 100566, 100566)).toEqual(projectDetail) + }) + + it('returns an empty object when the loaded project does not match the current project context', () => { + const projectDetail = { + id: '100566', + name: 'Project Phoenix' + } + + expect(getActiveProject(projectDetail, 100567, 100567)).toEqual({}) + }) +}) diff --git a/src/containers/Challenges/index.js b/src/containers/Challenges/index.js index 871f078b..e413cd79 100644 --- a/src/containers/Challenges/index.js +++ b/src/containers/Challenges/index.js @@ -22,6 +22,7 @@ import { } from '../../actions/sidebar' import { checkAdmin, checkIsUserInvitedToProject } from '../../util/tc' import { withRouter } from 'react-router-dom' +import { getActiveProject } from './helper' class Challenges extends Component { constructor (props) { @@ -134,6 +135,7 @@ class Challenges extends Component { setActiveProject, partiallyUpdateChallengeDetails, deleteChallenge, + projectId, isBillingAccountExpired, billingStartDate, billingEndDate, @@ -150,16 +152,17 @@ class Challenges extends Component { fetchNextProjects } = this.props const { challengeTypes = [] } = metadata - const isActiveProjectLoaded = - reduxProjectInfo && `${reduxProjectInfo.id}` === `${activeProjectId}` + const activeProject = getActiveProject( + reduxProjectInfo, + projectId, + activeProjectId + ) return ( {(dashboard || activeProjectId !== -1 || selfService) && ( { - return m.userId === userId - }) - - return _.get(found, 'role') + return getProjectMemberRole({ members }, userId) } checkIsCopilotOrManager (projectMembers, userId) { diff --git a/src/containers/Projects/index.js b/src/containers/Projects/index.js index 97fcc577..44968f5d 100644 --- a/src/containers/Projects/index.js +++ b/src/containers/Projects/index.js @@ -5,7 +5,7 @@ import { withRouter, Link } from 'react-router-dom' import { connect } from 'react-redux' import PropTypes from 'prop-types' import Loader from '../../components/Loader' -import { checkAdminOrCopilotOrManager, checkManager } from '../../util/tc' +import { checkCanCreateProject, checkManager } from '../../util/tc' import { PrimaryButton } from '../../components/Buttons' import Select from '../../components/Select' import ProjectCard from '../../components/ProjectCard' @@ -49,7 +49,7 @@ const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, load

Projects

- {checkAdminOrCopilotOrManager(auth.token) && ( + {checkCanCreateProject(auth.token) && ( diff --git a/src/util/tc.js b/src/util/tc.js index 60a814af..f306dfb1 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -21,6 +21,57 @@ import { decodeToken } from 'tc-auth-lib' import { fetchResources, fetchResourceRoles } from '../services/challenges' import store from '../config/store' +const TALENT_MANAGER_ROLES = [ + 'talent manager', + 'topcoder talent manager' +] + +const normalizeUserId = (userId) => { + if (_.isNil(userId)) { + return null + } + + const normalizedUserId = `${userId}`.trim() + return normalizedUserId.length > 0 ? normalizedUserId : null +} + +const normalizeEmail = (email) => { + if (_.isNil(email)) { + return null + } + + const normalizedEmail = `${email}`.trim().toLowerCase() + return normalizedEmail.length > 0 ? normalizedEmail : null +} + +const getProjectMember = (project, userId) => { + const normalizedUserId = normalizeUserId(userId) + + if ( + !project || + _.isEmpty(project) || + !normalizedUserId || + !Array.isArray(project.members) + ) { + return null + } + + return _.find( + project.members, + member => normalizeUserId(member.userId) === normalizedUserId + ) || null +} + +const canManageProject = (project, userId) => { + if (!project || _.isEmpty(project)) { + return true + } + + return ALLOWED_EDIT_RESOURCE_ROLES.includes( + _.get(getProjectMember(project, userId), 'role') + ) +} + export const RATING_COLORS = [ { color: '#9D9FA0' /* Grey */, @@ -213,8 +264,7 @@ export const checkManager = (token) => { export const checkTalentManager = (token) => { const tokenData = decodeToken(token) const roles = _.get(tokenData, 'roles') - const talentManagerRoles = ['talent manager', 'topcoder talent manager'] - return roles.some(val => talentManagerRoles.indexOf(val.toLowerCase()) > -1) + return roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) } export const checkAdminOrTalentManager = (token) => { @@ -227,13 +277,71 @@ export const checkTaskManager = (token) => { return roles.some(val => TASK_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) } -const normalizeUserId = (userId) => { - if (_.isNil(userId)) { - return null +/** + * Checks whether the caller can manage project ownership flows in Work Manager. + * + * Admins always qualify. Project Managers, Copilots, and Talent Managers + * additionally need a management-capable project membership when a project + * context is provided. + * + * @param token + * @param project + * @returns {boolean} Whether the caller can manage the project in the UI. + */ +export const checkCanManageProject = (token, project) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles') + const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) + const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) + const isManager = roles.some(val => MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) + const isTalentManager = roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) + const hasProjectManagementAccess = canManageProject(project, tokenData.userId) + + return isAdmin || ((isCopilot || isManager || isTalentManager) && hasProjectManagementAccess) +} + +/** + * Checks whether the caller may create a project in Work Manager. + * + * Project creation remains broader than edit permissions. Project Managers + * should still be able to create projects even though billing-account edits + * are limited to admins and Full Access members. + * + * @param token + * @returns {boolean} Whether the caller can create a project. + */ +export const checkCanCreateProject = (token) => { + return checkAdmin(token) || checkManager(token) || checkCopilot(token) +} + +/** + * Checks whether the caller may edit a project's billing account. + * + * This is intentionally stricter than general project-management checks: + * only admins or project members with Full Access (`manager`) qualify. + * + * @param token + * @param project + * @returns {boolean} Whether the caller can edit the project's billing account. + */ +export const checkCanManageProjectBillingAccount = (token, project) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles', []) + const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) + + if (isAdmin) { + return true } - const normalizedUserId = `${userId}`.trim() - return normalizedUserId.length ? normalizedUserId : null + return _.get(getProjectMember(project, tokenData.userId), 'role') === PROJECT_ROLES.MANAGER +} + +export const checkProjectMembership = (project, userId) => { + return !!getProjectMember(project, userId) +} + +export const getProjectMemberRole = (project, userId) => { + return _.get(getProjectMember(project, userId), 'role', null) } /** @@ -269,8 +377,11 @@ export const checkAdminOrPmOrTaskManager = (token, project) => { const isManager = roles.some(val => MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) const isTaskManager = roles.some(val => TASK_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) - const isProjectManager = - _.get(getProjectMemberByUserId(project, userId), 'role') === PROJECT_ROLES.MANAGER + const isProjectManager = project && !_.isEmpty(project) && + project.members && project.members.some(member => + normalizeUserId(member.userId) === normalizeUserId(userId) && + member.role === PROJECT_ROLES.MANAGER + ) return isAdmin || isManager || isTaskManager || isProjectManager } @@ -282,12 +393,8 @@ export const checkCopilot = (token, project) => { const tokenData = decodeToken(token) const roles = _.get(tokenData, 'roles') const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) - const canManageProject = !project || _.isEmpty(project) || - ALLOWED_EDIT_RESOURCE_ROLES.includes( - _.get(getProjectMemberByUserId(project, tokenData.userId), 'role') - ) - return isCopilot && canManageProject + return isCopilot && canManageProject(project, tokenData.userId) } /** @@ -299,12 +406,8 @@ export const checkAdminOrCopilot = (token, project) => { const roles = _.get(tokenData, 'roles') const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) - const canManageProject = !project || _.isEmpty(project) || - ALLOWED_EDIT_RESOURCE_ROLES.includes( - _.get(getProjectMemberByUserId(project, tokenData.userId), 'role') - ) - return isAdmin || (isCopilot && canManageProject) + return isAdmin || (isCopilot && canManageProject(project, tokenData.userId)) } /** @@ -360,10 +463,16 @@ export const checkIsUserInvitedToProject = (token, project) => { } const tokenData = decodeToken(token) - return project && !_.isEmpty(project) && (_.find(project.invites, d => ( - d.status === PROJECT_MEMBER_INVITE_STATUS_PENDING && - (d.userId === tokenData.userId || d.email === tokenData.email) - ))) + return project && !_.isEmpty(project) && (_.find( + project.invites, + d => ( + d.status === PROJECT_MEMBER_INVITE_STATUS_PENDING && + ( + normalizeUserId(d.userId) === normalizeUserId(tokenData.userId) || + normalizeEmail(d.email) === normalizeEmail(tokenData.email) + ) + ) + )) } /** diff --git a/src/util/tc.test.js b/src/util/tc.test.js new file mode 100644 index 00000000..a8c9c240 --- /dev/null +++ b/src/util/tc.test.js @@ -0,0 +1,216 @@ +/* global describe, it, expect, beforeEach, jest */ + +import { decodeToken } from 'tc-auth-lib' +import { PROJECT_MEMBER_INVITE_STATUS_PENDING, PROJECT_ROLES } from '../config/constants' +import { + checkCanCreateProject, + checkCanManageProject, + checkCanManageProjectBillingAccount, + checkIsUserInvitedToProject, + getProjectMemberByUserId +} from './tc' + +jest.mock('tc-auth-lib', () => ({ + decodeToken: jest.fn() +})) + +jest.mock('../services/challenges', () => ({ + fetchResources: jest.fn(), + fetchResourceRoles: jest.fn() +})) + +jest.mock('../config/store', () => ({ + getState: jest.fn() +})) + +describe('checkCanManageProjectBillingAccount', () => { + beforeEach(() => { + decodeToken.mockReset() + }) + + it('allows administrators to manage project billing accounts', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['administrator'] + }) + + expect( + checkCanManageProjectBillingAccount('token', { + members: [] + }) + ).toBe(true) + }) + + it('allows full-access project members to manage project billing accounts', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect( + checkCanManageProjectBillingAccount('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.MANAGER + }] + }) + ).toBe(true) + }) + + it('blocks project-manager roles without full-access project membership', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect( + checkCanManageProjectBillingAccount('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.WRITE + }] + }) + ).toBe(false) + }) + + it('blocks talent-manager roles without full-access project membership', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['talent manager'] + }) + + expect( + checkCanManageProjectBillingAccount('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.WRITE + }] + }) + ).toBe(false) + }) +}) + +describe('checkCanManageProject', () => { + beforeEach(() => { + decodeToken.mockReset() + }) + + it('allows project-manager roles to manage projects when they have full access', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect( + checkCanManageProject('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.MANAGER + }] + }) + ).toBe(true) + }) + + it('blocks project-manager roles from managing projects without full access', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect( + checkCanManageProject('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.WRITE + }] + }) + ).toBe(false) + }) +}) + +describe('checkCanCreateProject', () => { + beforeEach(() => { + decodeToken.mockReset() + }) + + it('allows project-manager roles to create projects', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect(checkCanCreateProject('token')).toBe(true) + }) + + it('allows copilots to create projects', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['copilot'] + }) + + expect(checkCanCreateProject('token')).toBe(true) + }) + + it('blocks read-only users from creating projects', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['topcoder user'] + }) + + expect(checkCanCreateProject('token')).toBe(false) + }) +}) + +describe('getProjectMemberByUserId', () => { + it('matches project members even when ids differ by string vs number types', () => { + expect(getProjectMemberByUserId({ + members: [{ + userId: '1001', + role: PROJECT_ROLES.WRITE + }] + }, 1001)).toEqual({ + userId: '1001', + role: PROJECT_ROLES.WRITE + }) + }) +}) + +describe('checkIsUserInvitedToProject', () => { + beforeEach(() => { + decodeToken.mockReset() + }) + + it('returns the pending invite for the authenticated user', () => { + decodeToken.mockReturnValue({ + userId: '1001', + email: 'member@test.com' + }) + + expect(checkIsUserInvitedToProject('token', { + invites: [{ + status: PROJECT_MEMBER_INVITE_STATUS_PENDING, + userId: '1001', + email: 'member@test.com' + }] + })).toEqual({ + status: PROJECT_MEMBER_INVITE_STATUS_PENDING, + userId: '1001', + email: 'member@test.com' + }) + }) + + it('ignores non-pending invites for the authenticated user', () => { + decodeToken.mockReturnValue({ + userId: '1001', + email: 'member@test.com' + }) + + expect(checkIsUserInvitedToProject('token', { + invites: [{ + status: 'declined', + userId: '1001', + email: 'member@test.com' + }] + })).toBeUndefined() + }) +})