diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..861ea06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +coverage +.nyc_output +npm-debug.log* +pnpm-debug.log* diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml deleted file mode 100644 index 82c7862..0000000 --- a/.github/workflows/code_reviewer.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: AI PR Reviewer - -on: - pull_request: - types: - - opened - - synchronize -permissions: - pull-requests: write -jobs: - tc-ai-pr-review: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v3 - - - name: TC AI PR Reviewer - uses: topcoder-platform/tc-ai-pr-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) - LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} - exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 11dd61c..75bbf0f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,9 +8,9 @@ COPY . /resources-api # Set working directory for future use WORKDIR /resources-api -RUN npm install pnpm -g +RUN npm install -g pnpm@10.33.2 # Install the dependencies from package.json -RUN pnpm install +RUN pnpm install --frozen-lockfile RUN pnpm lint RUN pnpm lint:fix diff --git a/package.json b/package.json index c3a605b..dd3ce32 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "TopCoder Challenge Resources V5 API", "main": "app.js", + "packageManager": "pnpm@10.33.2", "scripts": { "start": "node app.js", "start:dev": "nodemon app.js", @@ -61,6 +62,19 @@ "prisma": { "schema": "./prisma/schema.prisma" }, + "pnpm": { + "onlyBuiltDependencies": [ + "@prisma/client", + "@prisma/engines", + "prisma" + ], + "ignoredBuiltDependencies": [ + "@scarf/scarf", + "aws-sdk", + "core-js", + "dtrace-provider" + ] + }, "engines": { "node": ">=18 <23" }, diff --git a/prisma/challenge-schema.prisma b/prisma/challenge-schema.prisma index 8bf4fa1..2f57e94 100644 --- a/prisma/challenge-schema.prisma +++ b/prisma/challenge-schema.prisma @@ -11,5 +11,16 @@ datasource db { model Challenge { id String @id @default(uuid()) numOfRegistrants Int @default(0) + userWhitelist ChallengeUserWhitelist[] } +model ChallengeUserWhitelist { + challengeId String + userId String + + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + + @@id([challengeId, userId]) + @@index([challengeId]) + @@index([userId]) +} diff --git a/src/common/helper.js b/src/common/helper.js index beb8360..5e30a2c 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -231,28 +231,98 @@ async function getMemberDetailsById (memberId) { } /** - * Fetch challenge information from the challenge database and optionally from the Challenge API. - * Uses the Prisma challenge client to ensure the challenge exists before pulling additional details. + * Fetch challenge information by challenge id. + * + * When includeDetails is false, the challenge database is used for a lightweight + * existence check and a local NotFoundError is raised if the record is missing. + * When includeDetails is true, Challenge API remains authoritative for the full + * payload and its error contract, including the 404 response shape exposed by + * resource create/delete flows. * * @param {String} challengeId the challenge id * @param {Object} [options] optional parameters * @param {Boolean} [options.includeDetails=false] whether to fetch full challenge details from the API * @returns {Promise} the challenge record or detailed Challenge API payload + * @throws {NotFoundError} when includeDetails is false and the challenge database record is missing */ async function getChallengeById (challengeId, options = {}) { const { includeDetails = false } = options + + if (includeDetails) { + const response = await getRequest(`${config.CHALLENGE_API_URL}/${challengeId}`) + return _.get(response, 'body', null) + } + const challengeRecord = await prismaChallenge.challenge.findUnique({ where: { id: challengeId } }) if (!challengeRecord) { throw new errors.NotFoundError(`Challenge ID ${challengeId} not found`) } - if (!includeDetails) { - return challengeRecord + return challengeRecord +} + +/** + * Determine whether challenge whitelist checks apply for a request. + * Interactive users, including admins and anonymous callers, must be evaluated; + * M2M callers are allowed to bypass this user-facing access control. + * + * @param {Object} currentUser the user who performs the operation + * @returns {Boolean} true when whitelist rules should be applied + */ +function shouldApplyChallengeWhitelist (currentUser) { + return !_.get(currentUser, 'isMachine', false) +} + +/** + * Filter challenge ids by the current challenge user whitelist state. + * Challenges with no whitelist rows stay visible. Evaluation failures fail + * closed and return an empty list for interactive callers. + * + * @param {Object} currentUser the user who performs the operation + * @param {Array} challengeIds challenge ids to filter + * @returns {Promise>} challenge ids visible to the caller + */ +async function filterChallengeIdsByWhitelist (currentUser, challengeIds) { + const ids = _.uniq((challengeIds || []).map(id => _.toString(id).trim()).filter(Boolean)) + if (ids.length === 0 || !shouldApplyChallengeWhitelist(currentUser)) { + return ids + } + + const userId = _.toString(_.get(currentUser, 'userId', '')).trim() + + try { + const rows = await prismaChallenge.challengeUserWhitelist.findMany({ + where: { challengeId: { in: ids } }, + select: { challengeId: true, userId: true } + }) + const restrictedIds = new Set(rows.map(row => row.challengeId)) + const allowedRestrictedIds = new Set( + rows + .filter(row => userId && _.toString(row.userId) === userId) + .map(row => row.challengeId) + ) + + return ids.filter(id => !restrictedIds.has(id) || allowedRestrictedIds.has(id)) + } catch (err) { + logger.warn(`filterChallengeIdsByWhitelist failed: ${err.message}`) + return [] } +} - const response = await getRequest(`${config.CHALLENGE_API_URL}/${challengeId}`) - return _.get(response, 'body', null) +/** + * Ensure an interactive caller is allowed by the challenge whitelist. + * + * @param {Object} currentUser the user who performs the operation + * @param {String} challengeId the challenge id to evaluate + * @returns {Promise} + * @throws {ForbiddenError} when the whitelist blocks the caller or evaluation fails + */ +async function ensureChallengeWhitelistAccess (currentUser, challengeId) { + const visibleIds = await filterChallengeIdsByWhitelist(currentUser, [challengeId]) + if (!visibleIds.includes(challengeId)) { + throw new errors.ForbiddenError(`You don't have access to view this challenge`) + } } async function getMemberDetailsByHandleFromV3Members (handle) { @@ -573,6 +643,9 @@ module.exports = { setResHeaders, getAllPages, checkChallengeGroupAccess, + shouldApplyChallengeWhitelist, + filterChallengeIdsByWhitelist, + ensureChallengeWhitelistAccess, checkAgreedTerms, postRequest, advanceChallengePhase, diff --git a/src/controllers/ResourceController.js b/src/controllers/ResourceController.js index 67820f5..dc6e875 100644 --- a/src/controllers/ResourceController.js +++ b/src/controllers/ResourceController.js @@ -52,7 +52,7 @@ async function updatePhaseChangeNotifications (req, res) { * @param {Object} res the response */ async function listChallengesByMember (req, res) { - const result = await service.listChallengesByMember(req.params.memberId, req.query) + const result = await service.listChallengesByMember(req.params.memberId, req.query, req.authUser) helper.setResHeaders(req, res, result) res.send(result.data) } @@ -63,7 +63,7 @@ async function listChallengesByMember (req, res) { * @param {Object} res the response */ async function getResourceCount (req, res) { - const result = await service.getResourceCount(req.query.challengeId, req.query.roleId) + const result = await service.getResourceCount(req.query.challengeId, req.query.roleId, req.authUser) res.send(result) } diff --git a/src/services/ResourceService.js b/src/services/ResourceService.js index 6305168..04e17a3 100644 --- a/src/services/ResourceService.js +++ b/src/services/ResourceService.js @@ -27,6 +27,13 @@ const RESTRICTED_ROLE_NAMES = [ 'checkpoint reviewer', 'approver' ] +const RESOURCE_MANAGER_ROLE_NAMES = new Set([ + _.toLower(constants.UserRoles.Manager), + 'project manager', + 'topcoder project manager', + 'talent manager', + 'topcoder talent manager' +]) let copilotResourceRoleIdsCache let restrictedRoleIdsCache @@ -72,11 +79,25 @@ async function getRestrictedRoleIds () { return restrictedRoleIdsCache } +/** + * Check whether the current user has a Work manager role that can manage challenge resources. + * @param {Object} currentUser the current user + * @returns {Boolean} true when the user has a resource manager role + */ +function hasResourceManagerRole (currentUser) { + return _.some(_.get(currentUser, 'roles', []), role => RESOURCE_MANAGER_ROLE_NAMES.has(_.toLower(role))) +} + /** * Check whether the user can access resources + * @param {Object} currentUser the current user * @param {Array} resources resources of current user for specified challenge id */ -async function checkAccess (currentUserResources) { +async function checkAccess (currentUser, currentUserResources) { + if (hasResourceManagerRole(currentUser)) { + return + } + const copilotRoleIds = await getCopilotResourceRoleIds() const hasCopilotRole = _.some(currentUserResources, r => copilotRoleIds.includes(r.roleId)) if (hasCopilotRole) { @@ -125,6 +146,7 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH throw new errors.BadRequestError(`Challenge ID ${challengeId} must be a valid v5 Challenge Id (UUID)`) } if (challengeId) { + await helper.ensureChallengeWhitelistAccess(currentUser, challengeId) try { // Verify that the challenge exists await helper.getChallengeById(challengeId) @@ -165,7 +187,7 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH } }) try { - await checkAccess(resources) + await checkAccess(currentUser, resources) hasFullAccess = true } catch (e) { hasFullAccess = false @@ -215,12 +237,10 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH } const orderBy = [{ [sortBy]: sortOrder }] - const total = await prisma.resource.count(prismaFilter) + let total const prismaQuery = { ...prismaFilter, orderBy, - skip: (page - 1) * perPage, - take: perPage, include: { resourceRole: { select: { @@ -229,7 +249,23 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH } } } - let resources = await prisma.resource.findMany(prismaQuery) + let resources + if (!challengeId && helper.shouldApplyChallengeWhitelist(currentUser)) { + const allResources = await prisma.resource.findMany(prismaQuery) + const visibleChallengeIds = new Set( + await helper.filterChallengeIdsByWhitelist(currentUser, _.map(allResources, 'challengeId')) + ) + resources = allResources.filter(resource => visibleChallengeIds.has(resource.challengeId)) + total = resources.length + resources = resources.slice((page - 1) * perPage, page * perPage) + } else { + total = await prisma.resource.count(prismaFilter) + resources = await prisma.resource.findMany({ + ...prismaQuery, + skip: (page - 1) * perPage, + take: perPage + }) + } resources = _.map(resources, item => { const ret = _.omit(item, 'updatedBy', 'updatedAt', 'createdAt', 'resourceRole') ret.created = item.createdAt @@ -316,6 +352,8 @@ async function getResourceRole (roleId, isCreated) { * @returns {Promise} the resource entities and member information. */ async function init (currentUser, challengeId, resource, isCreated) { + await helper.ensureChallengeWhitelistAccess(currentUser, challengeId) + // Verify that the challenge exists const challenge = await helper.getChallengeById(challengeId, { includeDetails: true }) @@ -436,7 +474,7 @@ async function init (currentUser, challengeId, resource, isCreated) { if (!resourceRole.selfObtainable || _.toString(memberId) !== _.toString(currentUser.userId)) { // if user is not creating/deleting a self obtainable resource for itself // we need to perform check access first - await checkAccess(currentUserResources) + await checkAccess(currentUser, currentUserResources) } } } @@ -700,6 +738,7 @@ async function updatePhaseChangeNotifications (currentUser, resourceId, payload) const isMachineUser = Boolean(currentUser && currentUser.isMachine) const isAdminUser = Boolean(currentUser && helper.hasAdminRole(currentUser)) + await helper.ensureChallengeWhitelistAccess(currentUser, resource.challengeId) if (!isMachineUser && !isAdminUser) { if (!currentUser || _.toString(resource.memberId) !== _.toString(currentUser.userId)) { @@ -739,9 +778,10 @@ updatePhaseChangeNotifications.schema = { * List all challenge ids that given member has access to. * @param {Number} memberId the member id * @param {Object} criteria the criteria: {resourceRoleId, page, perPage} + * @param {Object} currentUser the user who performs the operation * @returns {Array} an array of challenge ids represents challenges that given member has access to. */ -async function listChallengesByMember (memberId, criteria) { +async function listChallengesByMember (memberId, criteria, currentUser) { const perPage = criteria.perPage || config.DEFAULT_PAGE_SIZE const page = criteria.page || 1 @@ -753,28 +793,22 @@ async function listChallengesByMember (memberId, criteria) { if (criteria.resourceRoleId) { prismaFilter.where.AND.push({ roleId: criteria.resourceRoleId }) } - // TODO: total count is total resource count, not distinct challengeId count - const total = await prisma.resource.count(prismaFilter) - - let records = [] - if (criteria.useScroll) { - records = await prisma.resource.findMany({ - ...selectClause, - ...prismaFilter - }) - } else { - records = await prisma.resource.findMany({ - ...selectClause, - ...prismaFilter, - skip: (page - 1) * perPage, - take: perPage - }) - } // convert to challengeId array and remove duplicated - const arr = _.uniq(_.map(records, 'challengeId')) + const records = await prisma.resource.findMany({ + ...selectClause, + ...prismaFilter, + orderBy: [{ challengeId: 'asc' }] + }) + const visibleChallengeIds = await helper.filterChallengeIdsByWhitelist( + currentUser, + _.uniq(_.map(records, 'challengeId')) + ) + const arr = criteria.useScroll + ? visibleChallengeIds + : visibleChallengeIds.slice((page - 1) * perPage, page * perPage) return { data: arr, - total, + total: visibleChallengeIds.length, page, perPage } @@ -787,17 +821,20 @@ listChallengesByMember.schema = { page: Joi.page().default(1), perPage: Joi.perPage().default(config.DEFAULT_PAGE_SIZE), useScroll: Joi.boolean().default(false) - }).required() + }).required(), + currentUser: Joi.any() } /** * Get resource count of a challenge. * @param {String} challengeId the challenge id * @param {String} roleId the role id to filter on + * @param {Object} currentUser the user who performs the operation * @returns {Object} the search result */ -async function getResourceCount (challengeId, roleId) { +async function getResourceCount (challengeId, roleId, currentUser) { logger.debug(`getResourceCount ${JSON.stringify([challengeId, roleId])}`) + await helper.ensureChallengeWhitelistAccess(currentUser, challengeId) const whereClause = { where: { AND: [] } } whereClause.where.AND.push({ challengeId }) if (roleId) { @@ -818,7 +855,8 @@ async function getResourceCount (challengeId, roleId) { getResourceCount.schema = { challengeId: Joi.id(), - roleId: Joi.optionalId() + roleId: Joi.optionalId(), + currentUser: Joi.any() } module.exports = { diff --git a/test/common/testData.js b/test/common/testData.js index e21d6d7..c37aeb6 100644 --- a/test/common/testData.js +++ b/test/common/testData.js @@ -55,6 +55,26 @@ const user = { email: 'email@domain.com.z', jti: 'f1e613be-d5b9-4231-baae-ee9f2d227234' }, + projectManager: { + roles: [ 'Topcoder User', 'project manager' ], + iss: 'https://api.topcoder-dev.com', + handle: 'projectmanager', + exp: 1561792370, + userId: '8123456', + iat: 1549791770, + email: 'email@domain.com.z', + jti: 'f1e613be-d5b9-4231-baae-ee9f2d227234' + }, + talentManager: { + roles: [ 'Topcoder User', 'talent manager' ], + iss: 'https://api.topcoder-dev.com', + handle: 'talentmanager', + exp: 1561792370, + userId: '8123457', + iat: 1549791770, + email: 'email@domain.com.z', + jti: 'f1e613be-d5b9-4231-baae-ee9f2d227234' + }, lunarkid: { roles: [ 'Topcoder User' ], iss: 'https://api.topcoder-dev.com', diff --git a/test/unit/createResource.test.js b/test/unit/createResource.test.js index 8b52055..a73cef7 100644 --- a/test/unit/createResource.test.js +++ b/test/unit/createResource.test.js @@ -216,6 +216,44 @@ module.exports = describe('Create resource', () => { await assertResource(ret.id, ret) }) + it('enforces challenge user whitelist for interactive resource creation', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId3 } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId: challengeId3, + userId: user.phead.userId + } + }) + + let createdResourceId + try { + const blockedEntity = resources.createBody('diazz', submitterRoleId, challengeId3) + try { + await service.createResource(user.diazz, blockedEntity) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + + const machineEntity = resources.createBody('machinewhitelistuser', submitterRoleId, challengeId3) + const ret = await service.createResource(user.m2m, machineEntity) + createdResourceId = ret.id + should.equal(ret.roleId, machineEntity.roleId) + should.equal(ret.memberHandle.toLowerCase(), machineEntity.memberHandle.toLowerCase()) + } finally { + if (createdResourceId) { + await prisma.resource.deleteMany({ + where: { id: createdResourceId } + }) + } + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId3 } + }) + } + }) + it('failure - create self obtainable resource for other user by normal user forbidden', async () => { const entity = resources.createBody('lunarkid', config.SUBMITTER_RESOURCE_ROLE_ID, challengeId3) try { diff --git a/test/unit/deleteResource.test.js b/test/unit/deleteResource.test.js index bc83120..a8dfa85 100644 --- a/test/unit/deleteResource.test.js +++ b/test/unit/deleteResource.test.js @@ -6,6 +6,7 @@ const _ = require('lodash') const should = require('should') const service = require('../../src/services/ResourceService') const helper = require('../../src/common/helper') +const prisma = require('../../src/common/prisma').getClient() const { requestBody, user } = require('../common/testData') const { assertValidationError, assertError, getRoleIds } = require('../common/testHelper') @@ -119,6 +120,41 @@ module.exports = describe('Delete resource', () => { } }) + it('enforces challenge user whitelist for interactive resource deletion', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId, + userId: user.phead.userId + } + }) + + try { + const entity = resources.createBody('diazz', submitterRoleId, challengeId) + try { + await service.deleteResource(user.admin, entity) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + + const existing = await prisma.resource.findFirst({ + where: { + challengeId, + memberHandle: 'diazz', + roleId: submitterRoleId + } + }) + should.exist(existing) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + } + }) + it('failure - delete resource for non-existed challenge', async () => { const entity = resources.createBody('ghostar', observerRoleId, challengeNotFoundId) try { diff --git a/test/unit/getResources.test.js b/test/unit/getResources.test.js index 0dd39eb..ee6b8d2 100644 --- a/test/unit/getResources.test.js +++ b/test/unit/getResources.test.js @@ -10,6 +10,8 @@ const { user } = require('../common/testData') const { assertValidationError, assertError, getRoleIds } = require('../common/testHelper') const challengeId = 'fe6d0a58-ce7d-4521-8501-b8132b1c0391' +const challengeId2 = 'fe6d0a58-ce7d-4521-8501-b8132b1c0392' +const challengeId3 = 'fe6d0a58-ce7d-4521-8501-b8132b1c0393' const challengeNotFoundId = '11111111-ce7d-4521-8501-b8132b1c0391' module.exports = describe('Get resources', () => { @@ -77,6 +79,126 @@ module.exports = describe('Get resources', () => { should.equal(hasReviewerRole, true) }) + it('get resources by project manager role', async () => { + const result = await service.getResources(user.projectManager, challengeId) + should.equal(result.total, 5) + for (const record of result.data) { + await assertResource(record.id, record) + should.exist(record.roleName) + } + }) + + it('get resources by talent manager role', async () => { + const result = await service.getResources(user.talentManager, challengeId) + should.equal(result.total, 5) + for (const record of result.data) { + await assertResource(record.id, record) + should.exist(record.roleName) + } + }) + + it('enforces challenge user whitelist for interactive resource reads', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId, + userId: user.phead.userId + } + }) + + try { + const allowed = await service.getResources(user.phead, challengeId) + should.equal(allowed.total, 5) + + const machine = await service.getResources(user.m2m, challengeId) + should.equal(machine.total, 5) + + try { + await service.getResources(user.admin, challengeId) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + } + }) + + it('filters resource lists before pagination metadata is computed', async () => { + await prisma.resource.updateMany({ + where: { challengeId, memberId: '151743' }, + data: { createdAt: new Date('2020-01-01T00:00:00.000Z') } + }) + await prisma.resource.updateMany({ + where: { challengeId: challengeId2, memberId: '151743' }, + data: { createdAt: new Date('2020-01-02T00:00:00.000Z') } + }) + await prisma.resource.updateMany({ + where: { challengeId: challengeId3, memberId: '151743' }, + data: { createdAt: new Date('2020-01-03T00:00:00.000Z') } + }) + + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId2 } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId: challengeId2, + userId: user.phead.userId + } + }) + + try { + const blocked = await service.getResources(user.admin, null, null, '151743', null, 2, 1, 'created', 'asc') + should.equal(blocked.total, 2) + should.equal(blocked.data.length, 1) + should.equal(blocked.data[0].challengeId, challengeId3) + + const machine = await service.getResources(user.m2m, null, null, '151743', null, 2, 1, 'created', 'asc') + should.equal(machine.total, 3) + should.equal(machine.data.length, 1) + should.equal(machine.data[0].challengeId, challengeId2) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId2 } + }) + } + }) + + it('enforces challenge user whitelist for resource count reads', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId, + userId: user.phead.userId + } + }) + + try { + try { + await service.getResourceCount(challengeId, null, user.admin) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + + const machine = await service.getResourceCount(challengeId, null, user.m2m) + should.equal(machine[submitterRoleId], 3) + should.equal(machine[copilotRoleId], 1) + should.equal(machine[reviewerRoleId], 1) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + } + }) + it('get resources by user has full-access permission', async () => { hasCopilotRole = false hasReviewerRole = false diff --git a/test/unit/listChallengesByMember.test.js b/test/unit/listChallengesByMember.test.js index 8c4ceff..f2e749a 100644 --- a/test/unit/listChallengesByMember.test.js +++ b/test/unit/listChallengesByMember.test.js @@ -4,6 +4,7 @@ const should = require('should') const service = require('../../src/services/ResourceService') +const helper = require('../../src/common/helper') const { assertValidationError, getRoleIds } = require('../common/testHelper') const challengeId1 = 'fe6d0a58-ce7d-4521-8501-b8132b1c0391' @@ -36,6 +37,51 @@ module.exports = describe('List challenges by member', () => { should.equal(ret.data.includes(challengeId3), true) }) + it('filters member challenge lists by challenge user whitelist', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId2 } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId: challengeId2, + userId: 'allowed-user' + } + }) + + try { + const blocked = await service.listChallengesByMember( + '151743', + {}, + { userId: 'blocked-user', roles: ['administrator'] } + ) + should.equal(blocked.data.length, 2) + should.equal(blocked.data.includes(challengeId1), true) + should.equal(blocked.data.includes(challengeId2), false) + should.equal(blocked.data.includes(challengeId3), true) + + const blockedSecondPage = await service.listChallengesByMember( + '151743', + { page: 2, perPage: 1 }, + { userId: 'blocked-user', roles: ['administrator'] } + ) + should.equal(blockedSecondPage.total, 2) + should.equal(blockedSecondPage.data.length, 1) + should.equal(blockedSecondPage.data[0], challengeId3) + + const allowed = await service.listChallengesByMember( + '151743', + {}, + { userId: 'allowed-user', roles: ['administrator'] } + ) + should.equal(allowed.data.length, 3) + should.equal(allowed.data.includes(challengeId2), true) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId2 } + }) + } + }) + it('get challenges ghostar can access with filter 1', async () => { const ret = await service.listChallengesByMember('151743', { resourceRoleId: submitterRoleId }) should.equal(ret.data.length, 1) diff --git a/test/unit/updatePhaseChangeNotifications.test.js b/test/unit/updatePhaseChangeNotifications.test.js index 6a487fd..87e283c 100644 --- a/test/unit/updatePhaseChangeNotifications.test.js +++ b/test/unit/updatePhaseChangeNotifications.test.js @@ -5,10 +5,13 @@ const should = require('should') const { v4: uuid } = require('uuid') const service = require('../../src/services/ResourceService') +const helper = require('../../src/common/helper') const prisma = require('../../src/common/prisma').getClient() const { user } = require('../common/testData') const { assertValidationError, assertError, getRoleIds } = require('../common/testHelper') +const challengeId = 'fe6d0a58-ce7d-4521-8501-b8132b1c0391' + module.exports = describe('Update phase change notifications', () => { let submitterRoleId let createdResourceIds = [] @@ -82,6 +85,35 @@ module.exports = describe('Update phase change notifications', () => { should.equal(result.phaseChangeNotifications, false) }) + it('enforces challenge user whitelist for interactive notification updates', async () => { + const record = await createResourceForUser(user.diazz, { challengeId }) + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId, + userId: user.phead.userId + } + }) + + try { + try { + await service.updatePhaseChangeNotifications(user.admin, record.id, { phaseChangeNotifications: false }) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + + const updated = await service.updatePhaseChangeNotifications(user.m2m, record.id, { phaseChangeNotifications: false }) + should.equal(updated.phaseChangeNotifications, false) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + } + }) + it('allows machine-to-machine tokens with update scope to update any resource', async () => { const record = await createResourceForUser(user.diazz)