From b2e37ff507c4967eaa57daeab04bf13ecc3cf319 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Fri, 1 May 2026 11:45:16 -0400 Subject: [PATCH 01/20] FixeS --- src/repositories/baseOrgRepository.js | 2 +- src/repositories/orgRepository.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index a4edec5af..349b890af 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -152,7 +152,7 @@ class BaseOrgRepository extends BaseRepository { async findOneByShortName (shortName, options = {}, returnLegacyFormat = false, projection = {}) { const OrgRepository = require('./orgRepository') const legacyOrgRepo = new OrgRepository() - if (returnLegacyFormat) return await legacyOrgRepo.findOneByShortName(shortName, options) + if (returnLegacyFormat) return await legacyOrgRepo.findOneByShortName(shortName, options, projection) const data = await BaseOrgModel.findOne({ short_name: shortName }, projection, options) return data } diff --git a/src/repositories/orgRepository.js b/src/repositories/orgRepository.js index 099cc2fa8..d514434dd 100644 --- a/src/repositories/orgRepository.js +++ b/src/repositories/orgRepository.js @@ -7,9 +7,9 @@ class OrgRepository extends BaseRepository { super(Org) } - async findOneByShortName (shortName, options = {}) { + async findOneByShortName (shortName, options = {}, projection = {}) { const query = { short_name: shortName } - return this.collection.findOne(query, null, options) + return this.collection.findOne(query, projection, options) } async findOneByUUID (UUID) { From 2babf8457c6c236d9c3738306cdb5404b9699ca0 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Fri, 1 May 2026 13:49:05 -0400 Subject: [PATCH 02/20] Cleanup return values from review objects --- src/repositories/reviewObjectRepository.js | 56 ++++++++++--------- .../review-object/reviewObjectTest.js | 19 +++++++ 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/repositories/reviewObjectRepository.js b/src/repositories/reviewObjectRepository.js index 61400d343..c7629845c 100644 --- a/src/repositories/reviewObjectRepository.js +++ b/src/repositories/reviewObjectRepository.js @@ -2,6 +2,18 @@ const ReviewObjectModel = require('../model/reviewobject') const BaseRepository = require('./baseRepository') const BaseOrgRepository = require('./baseOrgRepository') const uuid = require('uuid') +const _ = require('lodash') +const getConstants = require('../constants').getConstants + +function filterReviewOrgData (orgData, isSecretariat = false) { + if (!orgData) return orgData + const CONSTANTS = getConstants() + let fieldsToOmit = [...CONSTANTS.ORG_EXCLUDED_FIELDS] + if (!isSecretariat) { + fieldsToOmit = [...fieldsToOmit, ...CONSTANTS.ORG_RESTRICTED_FIELDS] + } + return _.omit(orgData, fieldsToOmit) +} class ReviewObjectRepository extends BaseRepository { constructor () { @@ -14,13 +26,13 @@ class ReviewObjectRepository extends BaseRepository { if (!org) { return null } - const reviewObject = await ReviewObjectModel.find({ target_object_uuid: org.UUID }, null, options) + const reviewObject = await ReviewObjectModel.find({ target_object_uuid: org.UUID }, { _id: 0, __v: 0 }, options) return reviewObject || null } async findOneByUUID (UUID, options = {}) { - const reviewObject = await ReviewObjectModel.findOne({ uuid: UUID }, null, options) + const reviewObject = await ReviewObjectModel.findOne({ uuid: UUID }, { _id: 0, __v: 0 }, options) return reviewObject || null } @@ -29,21 +41,19 @@ class ReviewObjectRepository extends BaseRepository { const conversationRepository = new ConversationRepository() let reviewObject const query = pending ? { uuid: UUID, status: 'pending' } : { uuid: UUID } - const reviewObjectRaw = await ReviewObjectModel.findOne(query, null, options) + const reviewObjectRaw = await ReviewObjectModel.findOne(query, { _id: 0, __v: 0 }, options) if (reviewObjectRaw) { reviewObject = reviewObjectRaw.toObject() const conversations = await conversationRepository.getAllByTargetUUID(reviewObject.target_object_uuid, isSecretariat, options) reviewObject.conversation = conversations?.length ? conversations : undefined - if (!isSecretariat && reviewObject.new_review_data && reviewObject.new_review_data.program_data) { - delete reviewObject.new_review_data.program_data - } + reviewObject.new_review_data = filterReviewOrgData(reviewObject.new_review_data, isSecretariat) } return reviewObject || null } async getAllReviewObjects (options = {}) { - const reviewObjects = await ReviewObjectModel.find({}, null, { + const reviewObjects = await ReviewObjectModel.find({}, { _id: 0, __v: 0 }, { ...options, sort: { created: -1 } }) @@ -60,7 +70,8 @@ class ReviewObjectRepository extends BaseRepository { const agt = [ { $match: query }, - { $sort: { created: -1 } } + { $sort: { created: -1 } }, + { $project: { _id: 0, __v: 0 } } ] const pg = await this.aggregatePaginate(agt, options) @@ -96,7 +107,7 @@ class ReviewObjectRepository extends BaseRepository { target_object_uuid: org.UUID, status: 'pending' }, - null, + { _id: 0, __v: 0 }, { ...options, sort: { created: -1 } @@ -106,9 +117,7 @@ class ReviewObjectRepository extends BaseRepository { reviewObject = reviewObjectRaw.toObject() const conversations = await conversationRepository.getAllByTargetUUID(org.UUID, isSecretariat, options) reviewObject.conversation = conversations?.length ? conversations : undefined - if (!isSecretariat && reviewObject.new_review_data && reviewObject.new_review_data.program_data) { - delete reviewObject.new_review_data.program_data - } + reviewObject.new_review_data = filterReviewOrgData(reviewObject.new_review_data, isSecretariat) } return reviewObject || null @@ -128,7 +137,7 @@ class ReviewObjectRepository extends BaseRepository { target_object_uuid: org.UUID, status: 'pending' }, - null, + { _id: 0, __v: 0 }, { ...options, sort: { created: -1 } @@ -138,9 +147,7 @@ class ReviewObjectRepository extends BaseRepository { reviewObject = reviewObjectRaw.toObject() const conversations = await conversationRepository.getAllByTargetUUID(org.UUID, isSecretariat, options) reviewObject.conversation = conversations?.length ? conversations : undefined - if (!isSecretariat && reviewObject.new_review_data && reviewObject.new_review_data.program_data) { - delete reviewObject.new_review_data.program_data - } + reviewObject.new_review_data = filterReviewOrgData(reviewObject.new_review_data, isSecretariat) } return reviewObject || null @@ -160,7 +167,7 @@ class ReviewObjectRepository extends BaseRepository { const reviewObject = new ReviewObjectModel(reviewObjectRaw) await reviewObject.save(options) - return reviewObject.toObject() + return _.omit(reviewObject.toObject(), ['_id', '__v']) } async updateReviewOrgObject (body, UUID, options = {}) { @@ -173,7 +180,7 @@ class ReviewObjectRepository extends BaseRepository { reviewObject.new_review_data = body const result = await reviewObject.save(options) - return result.toObject() + return _.omit(result.toObject(), ['_id', '__v']) } async approveReviewOrgObject (UUID, approverUsername, options = {}) { @@ -191,7 +198,7 @@ class ReviewObjectRepository extends BaseRepository { } await reviewObject.save(options) - return reviewObject.toObject() + return _.omit(reviewObject.toObject(), ['_id', '__v']) } /** @@ -207,7 +214,8 @@ class ReviewObjectRepository extends BaseRepository { const agt = [ { $match: { target_object_uuid: org.UUID } }, - { $sort: { created: -1 } } + { $sort: { created: -1 } }, + { $project: { _id: 0, __v: 0 } } ] const pg = await this.aggregatePaginate(agt, options) @@ -221,11 +229,9 @@ class ReviewObjectRepository extends BaseRepository { data.nextPage = pg.nextPage } - if (!isSecretariat && data.reviewObjects) { + if (data.reviewObjects) { for (const review of data.reviewObjects) { - if (review.new_review_data && review.new_review_data.program_data) { - delete review.new_review_data.program_data - } + review.new_review_data = filterReviewOrgData(review.new_review_data, isSecretariat) } } @@ -273,7 +279,7 @@ class ReviewObjectRepository extends BaseRepository { } await reviewObject.save(options) - return reviewObject.toObject() + return _.omit(reviewObject.toObject(), ['_id', '__v']) } } module.exports = ReviewObjectRepository diff --git a/test/integration-tests/review-object/reviewObjectTest.js b/test/integration-tests/review-object/reviewObjectTest.js index 6fa5fa212..01e49f705 100644 --- a/test/integration-tests/review-object/reviewObjectTest.js +++ b/test/integration-tests/review-object/reviewObjectTest.js @@ -40,6 +40,8 @@ describe('Review Object Controller Integration Tests', () => { expect(res.body).to.have.property('target_object_uuid', orgUUID) expect(res.body).to.have.property('new_review_data') expect(res.body.status).to.equal('pending') + expect(res.body).to.not.have.property('_id') + expect(res.body).to.not.have.property('__v') reviewUUID = res.body.uuid }) @@ -50,6 +52,8 @@ describe('Review Object Controller Integration Tests', () => { .set({ ...constants.headers }) expect(res).to.have.status(200) expect(res.body).to.have.property('uuid', reviewUUID) + expect(res.body).to.not.have.property('_id') + expect(res.body).to.not.have.property('__v') }) it('Retrieves the review object by org UUID', async () => { @@ -59,6 +63,8 @@ describe('Review Object Controller Integration Tests', () => { .set({ ...constants.headers }) expect(res).to.have.status(200) expect(res.body).to.have.property('uuid', reviewUUID) + expect(res.body).to.not.have.property('_id') + expect(res.body).to.not.have.property('__v') }) it('Retrieves the review object by review UUID', async () => { @@ -81,6 +87,10 @@ describe('Review Object Controller Integration Tests', () => { expect(res.body.reviewObjects).to.be.an('array') const found = res.body.reviewObjects.find(obj => obj.uuid === reviewUUID) expect(found).to.exist + if (res.body.reviewObjects.length > 0) { + expect(res.body.reviewObjects[0]).to.not.have.property('_id') + expect(res.body.reviewObjects[0]).to.not.have.property('__v') + } }) it('Updates the review object with new short_name', async () => { @@ -283,6 +293,9 @@ describe('Review Object Controller Integration Tests', () => { expect(res.body.reviewObjects).to.be.an('array') if (res.body.reviewObjects.length > 0) { expect(res.body.reviewObjects[0].new_review_data).to.not.have.property('program_data') + expect(res.body.reviewObjects[0].new_review_data).to.not.have.property('inUse') + expect(res.body.reviewObjects[0].new_review_data).to.not.have.property('_id') + expect(res.body.reviewObjects[0].new_review_data).to.not.have.property('__v') } }) @@ -296,6 +309,9 @@ describe('Review Object Controller Integration Tests', () => { expect(res.body.reviewObjects).to.be.an('array') if (res.body.reviewObjects.length > 0) { expect(res.body.reviewObjects[0].new_review_data).to.have.property('program_data') + expect(res.body.reviewObjects[0].new_review_data).to.not.have.property('inUse') + expect(res.body.reviewObjects[0].new_review_data).to.not.have.property('_id') + expect(res.body.reviewObjects[0].new_review_data).to.not.have.property('__v') } }) @@ -306,6 +322,9 @@ describe('Review Object Controller Integration Tests', () => { .set({ ...constants.nonSecretariatUserHeaders2 }) expect(res).to.have.status(200) expect(res.body.new_review_data).to.not.have.property('program_data') + expect(res.body.new_review_data).to.not.have.property('inUse') + expect(res.body.new_review_data).to.not.have.property('_id') + expect(res.body.new_review_data).to.not.have.property('__v') }) // ------------------------------------------------------------------------------------------------ From e2fbbdb08548f3343f73d740987c91d869f5df43 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Fri, 1 May 2026 14:53:54 -0400 Subject: [PATCH 03/20] Attempting to fix the user being in admin array but not in user array --- src/repositories/baseUserRepository.js | 3 + src/repositories/reviewObjectRepository.js | 6 +- test/integration-tests/user/updateUserTest.js | 82 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index be6164758..d328161cc 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -448,6 +448,9 @@ class BaseUserRepository extends BaseRepository { // Remove us from the old users Array const filteredUuids = registryOrg.users.filter(uuid => uuid !== registryUser.UUID) registryOrg.users = filteredUuids + if (registryOrg.admins && registryOrg.admins.includes(registryUser.UUID)) { + registryOrg.admins = registryOrg.admins.filter(uuid => uuid !== registryUser.UUID) + } // Add us to the new org (this is a genuine cross-org migration, so we must fetch the new org) const newOrg = await baseOrgRepository.getOrgObject(incomingParameters.org_short_name) newOrg.users = [...new Set([...newOrg.users, registryUser.UUID])] diff --git a/src/repositories/reviewObjectRepository.js b/src/repositories/reviewObjectRepository.js index c7629845c..19730b6ae 100644 --- a/src/repositories/reviewObjectRepository.js +++ b/src/repositories/reviewObjectRepository.js @@ -172,7 +172,7 @@ class ReviewObjectRepository extends BaseRepository { async updateReviewOrgObject (body, UUID, options = {}) { console.log('Updating review object with UUID:', UUID) - const reviewObject = await this.findOneByUUID(UUID, options) + const reviewObject = await ReviewObjectModel.findOne({ uuid: UUID }, null, options) if (!reviewObject) { return null } @@ -185,7 +185,7 @@ class ReviewObjectRepository extends BaseRepository { async approveReviewOrgObject (UUID, approverUsername, options = {}) { console.log('Approving review object with UUID:', UUID) - const reviewObject = await this.findOneByUUID(UUID, options) + const reviewObject = await ReviewObjectModel.findOne({ uuid: UUID }, null, options) if (!reviewObject) { return null } @@ -266,7 +266,7 @@ class ReviewObjectRepository extends BaseRepository { async rejectReviewOrgObject (UUID, rejectorUsername, options = {}) { console.log('Rejecting review object with UUID:', UUID) - const reviewObject = await this.findOneByUUID(UUID, options) + const reviewObject = await ReviewObjectModel.findOne({ uuid: UUID }, null, options) if (!reviewObject) { return null } diff --git a/test/integration-tests/user/updateUserTest.js b/test/integration-tests/user/updateUserTest.js index 3871a4b2a..ef8d00eed 100644 --- a/test/integration-tests/user/updateUserTest.js +++ b/test/integration-tests/user/updateUserTest.js @@ -10,6 +10,88 @@ const app = require('../../../src/index.js') describe('Testing Edit user endpoint', () => { context('Positive Tests', () => { + it('Should correctly remove an admin from the original organization admins array when migrated to a new organization', async () => { + // Create org A + const orgA = 'mig_org_a' + await chai.request(app) + .post('/api/registry/org') + .set(constants.headers) + .send({ + short_name: orgA, + long_name: 'Migration Org A', + authority: ['CNA'], + hard_quota: 1000 + }) + .then(res => { + expect(res.status).to.equal(200, JSON.stringify(res.body)) + }) + // Create org B + const orgB = 'mig_org_b' + await chai.request(app) + .post('/api/registry/org') + .set(constants.headers) + .send({ + short_name: orgB, + long_name: 'Migration Org B', + authority: ['CNA'], + hard_quota: 1000 + }) + .then(res => { + expect(res.status).to.equal(200, JSON.stringify(res.body)) + }) + + // Create Admin user in org A + const username = 'mig_admin@mig_org_a.com' + await chai.request(app) + .post(`/api/registry/org/${orgA}/user`) + .set(constants.headers) + .send({ + username, + role: 'ADMIN', + name: { + first: 'Admin', + last: 'User' + }, + status: 'active' + }) + .then(res => { + expect(res.status).to.equal(200, JSON.stringify(res.body)) + }) + // Verify user is in orgA's admins array + const BaseOrgModel = require('../../../src/model/baseorg') + const orgARecordBefore = await BaseOrgModel.findOne({ short_name: orgA }) + expect(orgARecordBefore.admins).to.be.an('array') + // We need the user's UUID + let userResponse + await chai.request(app).get(`/api/registry/org/${orgA}/user/${username}`).set(constants.headers).then((res) => { userResponse = res.body }) + delete userResponse.created_by + delete userResponse.created + delete userResponse.last_updated + + const userUUID = userResponse.UUID + expect(orgARecordBefore.admins).to.include(userUUID) + + // Migrate user to org B + await chai.request(app) + .put(`/api/registry/org/${orgA}/user/${username}`) + .set(constants.headers) + .send({ + ...userResponse, + org_short_name: orgB + }) + .then((res) => { + expect(res).to.have.status(200) + }) + + // Verify user is removed from orgA + const orgARecordAfter = await BaseOrgModel.findOne({ short_name: orgA }) + expect(orgARecordAfter.admins).to.not.include(userUUID) + + // Verify user is in orgB's admins array + const orgBRecordAfter = await BaseOrgModel.findOne({ short_name: orgB }) + expect(orgBRecordAfter.admins).to.include(userUUID) + }) + it('Should return 200 when only name changes are done', async () => { await chai.request(app) .put('/api/org/win_5/user/jasminesmith@win_5.com?name.first=NewName') From 37705084ce584ece0e4e9ac86b3364d81e201283 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 09:54:26 -0400 Subject: [PATCH 04/20] Aliases must now be globally unique --- src/controller/org.controller/error.js | 7 +++ .../org.controller/org.controller.js | 25 +++++++++++ .../registry-org.controller/error.js | 7 +++ .../registry-org.controller.js | 23 ++++++++++ src/repositories/baseOrgRepository.js | 43 +++++++++++++++++++ .../registry-org/registryOrgCRUDTest.js | 29 +++++++++++++ 6 files changed, 134 insertions(+) diff --git a/src/controller/org.controller/error.js b/src/controller/org.controller/error.js index cde82381f..67c305c31 100644 --- a/src/controller/org.controller/error.js +++ b/src/controller/org.controller/error.js @@ -36,6 +36,13 @@ class OrgControllerError extends idrErr.IDRError { return err } + aliasCollision (conflictingString) { + const err = {} + err.error = 'ALIAS_COLLISION' + err.message = `The organization could not be created or updated because the string '${conflictingString}' is already in use as a short_name, name, or alias by another organization.` + return err + } + userExists (username) { // org const err = {} err.error = 'USER_EXISTS' diff --git a/src/controller/org.controller/org.controller.js b/src/controller/org.controller/org.controller.js index 4af27cbd3..7bea3aebf 100644 --- a/src/controller/org.controller/org.controller.js +++ b/src/controller/org.controller/org.controller.js @@ -281,6 +281,18 @@ async function createOrg (req, res, next) { return res.status(400).json(error.orgExists(body?.short_name)) } + // Check for alias collisions + const collisionString = await repo.checkAliasCollisions(body?.short_name, body?.name, body?.aliases, null, { session }) + if (collisionString) { + logger.info({ + uuid: req.ctx.uuid, + message: `${body?.short_name} organization was not created because the string '${collisionString}' collides with another organization's short_name, name, or alias.` + }) + await session.abortTransaction() + return res.status(400).json(error.aliasCollision(collisionString)) + } + + const userRepo = req.ctx.repositories.getBaseUserRepository() const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org, { session }) const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) @@ -363,6 +375,19 @@ async function updateOrg (req, res, next) { return res.status(403).json(error.duplicateShortname(queryParametersJson.new_short_name)) } + // Check for alias collisions + const shortNameToExclude = shortNameUrlParameter + const collisionString = await orgRepository.checkAliasCollisions(queryParametersJson.new_short_name || shortNameUrlParameter, queryParametersJson.name, queryParametersJson.aliases, shortNameToExclude, { session }) + if (collisionString) { + logger.info({ + uuid: req.ctx.uuid, + message: `${shortNameUrlParameter} organization could not be updated because the string '${collisionString}' collides with another organization's short_name, name, or alias.` + }) + await session.abortTransaction() + return res.status(400).json(error.aliasCollision(collisionString)) + } + + const userRepo = req.ctx.repositories.getBaseUserRepository() const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) const isSecretariat = await orgRepository.isSecretariatByShortName(req.ctx.org, { session }) diff --git a/src/controller/registry-org.controller/error.js b/src/controller/registry-org.controller/error.js index aa54d2fc1..b105e373e 100644 --- a/src/controller/registry-org.controller/error.js +++ b/src/controller/registry-org.controller/error.js @@ -36,6 +36,13 @@ class RegistryOrgControllerError extends idrErr.IDRError { return err } + aliasCollision (conflictingString) { + const err = {} + err.error = 'ALIAS_COLLISION' + err.message = `The organization could not be created or updated because the string '${conflictingString}' is already in use as a short_name, name, or alias by another organization.` + return err + } + userExists (username) { // org const err = {} err.error = 'USER_EXISTS' diff --git a/src/controller/registry-org.controller/registry-org.controller.js b/src/controller/registry-org.controller/registry-org.controller.js index 0b247fbe6..5f77139c2 100644 --- a/src/controller/registry-org.controller/registry-org.controller.js +++ b/src/controller/registry-org.controller/registry-org.controller.js @@ -177,6 +177,17 @@ async function createOrg (req, res, next) { return res.status(400).json(error.orgExists(body?.short_name)) } + // Check for alias collisions + const collisionString = await repo.checkAliasCollisions(body?.short_name, body?.name, body?.aliases, null, { session }) + if (collisionString) { + logger.info({ + uuid: req.ctx.uuid, + message: `${body?.short_name} organization was not created because the string '${collisionString}' collides with another organization's short_name, name, or alias.` + }) + await session.abortTransaction() + return res.status(400).json(error.aliasCollision(collisionString)) + } + const userRepo = req.ctx.repositories.getBaseUserRepository() const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) // Create the org – repo.createOrg will handle field mapping @@ -327,6 +338,18 @@ async function updateOrg (req, res, next) { return res.status(400).json(error.duplicateShortname(body?.short_name)) } + // Check for alias collisions + const shortNameToExclude = shortName + const collisionString = await repo.checkAliasCollisions(body?.short_name || shortName, body?.name, body?.aliases, shortNameToExclude, { session }) + if (collisionString) { + logger.info({ + uuid: req.ctx.uuid, + message: `${shortName} organization could not be updated because the string '${collisionString}' collides with another organization's short_name, name, or alias.` + }) + await session.abortTransaction() + return res.status(400).json(error.aliasCollision(collisionString)) + } + // Handle secretariat "stomping" of pending review objects if (isSecretariat) { const reviewRepo = req.ctx.repositories.getReviewObjectRepository() diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 349b890af..de503ca12 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -204,6 +204,49 @@ class BaseOrgRepository extends BaseRepository { return false } + /** + * @async + * @function checkAliasCollisions + * @description Checks if the provided short_name, name, or aliases collide with any other organization's short_name, long_name, or aliases. + * @param {string} shortName - The proposed short name of the organization. + * @param {string} name - The proposed name of the organization. + * @param {string[]} aliases - The proposed aliases of the organization. + * @param {string} [shortNameToExclude=null] - The short name of the organization to exclude from the check (e.g., during an update). + * @param {object} [options={}] - Optional settings for the repository query. + * @returns {Promise} The conflicting string if a collision is found, otherwise null. + */ + async checkAliasCollisions (shortName, name, aliases, shortNameToExclude = null, options = {}) { + const searchStrings = [shortName, name, ...(aliases || [])].filter(str => str != null && str !== '') + if (searchStrings.length === 0) { + return null + } + + const query = { + $or: [ + { short_name: { $in: searchStrings } }, + { long_name: { $in: searchStrings } }, + { aliases: { $in: searchStrings } } + ] + } + + if (shortNameToExclude) { + query.short_name = { $ne: shortNameToExclude } + } + + const collisionOrg = await BaseOrgModel.findOne(query, 'short_name long_name aliases', options) + if (collisionOrg) { + // Determine which string collided for better error reporting + for (const str of searchStrings) { + if (collisionOrg.short_name === str || collisionOrg.long_name === str || (collisionOrg.aliases && collisionOrg.aliases.includes(str))) { + return str + } + } + return searchStrings[0] // Fallback + } + + return null + } + /** * @async * @function addUserToOrg diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 60886b86f..052bd756c 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -146,6 +146,21 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.errors[0].instancePath).to.equal('/partner_role_type') }) }) + it('Fails to create a new registry organization with an alias that collides with an existing short_name', async () => { + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send({ + ...testRegistryOrg, + short_name: 'test_create_alias_collision', + aliases: ['mitre'] + }) + .then((res) => { + expect(res).to.have.status(400) + expect(res.body.error).to.equal('ALIAS_COLLISION') + expect(res.body.message).to.equal("The organization could not be created or updated because the string 'mitre' is already in use as a short_name, name, or alias by another organization.") + }) + }) }) }) context('Testing GET /registryOrg endpoints', () => { @@ -470,6 +485,20 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.message).to.equal("The organization cannot be renamed as 'mitre' because this shortname is used by another organization.") }) }) + it("Fails to update a registry organization with an alias that collides with an existing short_name", async () => { + await chai.request(app) + .put('/api/registry/org/registry_org_test') + .set(secretariatHeaders) + .send({ + ...createdOrg, + aliases: ['mitre'] + }) + .then((res) => { + expect(res).to.have.status(400) + expect(res.body.error).to.equal('ALIAS_COLLISION') + expect(res.body.message).to.equal("The organization could not be created or updated because the string 'mitre' is already in use as a short_name, name, or alias by another organization.") + }) + }) it('Fails to update a registry organization providing invalid data', async () => { await chai.request(app) .put('/api/registry/org/registry_org_test') From a00e7447a8c3e3e21234ed8ed60df7a8a9666b03 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 10:24:46 -0400 Subject: [PATCH 05/20] remove the endpoints to approve / create review objects on their own --- .../review-object.controller/index.js | 158 ------------------ 1 file changed, 158 deletions(-) diff --git a/src/controller/review-object.controller/index.js b/src/controller/review-object.controller/index.js index 702da7529..70bf0e3be 100644 --- a/src/controller/review-object.controller/index.js +++ b/src/controller/review-object.controller/index.js @@ -406,90 +406,6 @@ router.put('/review/:uuid', controller.updateReviewObjectByReviewUUID ) -// Approve a review object -router.put('/review/:uuid/approve', - /* - #swagger.tags = ['Review Object'] - #swagger.operationId = 'approveReviewObject' - #swagger.summary = "Approves a review object and applies changes to the organization (accessible to Secretariat only)" - #swagger.description = " -

Access Control

-

User must belong to an organization with the Secretariat role

" - #swagger.parameters['uuid'] = { description: 'The UUID of the review object' } - #swagger.parameters['$ref'] = [ - '#/components/parameters/apiEntityHeader', - '#/components/parameters/apiUserHeader', - '#/components/parameters/apiSecretHeader' - ] - #swagger.requestBody = { - required: false, - content: { - 'application/json': { - schema: { - type: 'object', - description: 'Optional override data to apply instead of the review object data' - } - } - } - } - #swagger.responses[200] = { - description: 'Returns the updated organization', - content: { - "application/json": { - schema: { - type: 'object', - description: 'The updated organization object' - } - } - } - } - #swagger.responses[400] = { - description: 'Bad Request', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/bad-request.json' } - } - } - } - #swagger.responses[401] = { - description: 'Not Authenticated', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/generic.json' } - } - } - } - #swagger.responses[403] = { - description: 'Forbidden', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/generic.json' } - } - } - } - #swagger.responses[404] = { - description: 'Not Found', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/generic.json' } - } - } - } - #swagger.responses[500] = { - description: 'Internal Server Error', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/generic.json' } - } - } - } - */ - mw.useRegistry(), - mw.validateUser, - mw.onlySecretariat, - controller.approveReviewObject -) - // Reject a review object router.put('/review/:uuid/reject', /* @@ -562,78 +478,4 @@ router.put('/review/:uuid/reject', controller.rejectReviewObject ) -// Create a review object -router.post('/review/org/', - /* - #swagger.tags = ['Review Object'] - #swagger.operationId = 'createReviewObject' - #swagger.summary = "Creates a new review object (accessible to Secretariat only)" - #swagger.description = " -

Access Control

-

User must belong to an organization with the Secretariat role

" - #swagger.parameters['$ref'] = [ - '#/components/parameters/apiEntityHeader', - '#/components/parameters/apiUserHeader', - '#/components/parameters/apiSecretHeader' - ] - #swagger.requestBody = { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - description: 'The review object data' - } - } - } - } - #swagger.responses[200] = { - description: 'Returns the created review object', - content: { - "application/json": { - schema: { - $ref: '../schemas/review/review.json' - } - } - } - } - #swagger.responses[400] = { - description: 'Bad Request', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/bad-request.json' } - } - } - } - #swagger.responses[401] = { - description: 'Not Authenticated', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/generic.json' } - } - } - } - #swagger.responses[403] = { - description: 'Forbidden', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/generic.json' } - } - } - } - #swagger.responses[500] = { - description: 'Internal Server Error', - content: { - "application/json": { - schema: { $ref: '../schemas/errors/generic.json' } - } - } - } - */ - mw.useRegistry(), - mw.validateUser, - mw.onlySecretariat, - controller.createReviewObject -) - module.exports = router From 66bc78daaa8984b93a1745367bc94a3b97f15ee6 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 10:43:20 -0400 Subject: [PATCH 06/20] Revert "remove the endpoints to approve / create review objects on their own" This reverts commit a00e7447a8c3e3e21234ed8ed60df7a8a9666b03. --- .../review-object.controller/index.js | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/controller/review-object.controller/index.js b/src/controller/review-object.controller/index.js index 70bf0e3be..702da7529 100644 --- a/src/controller/review-object.controller/index.js +++ b/src/controller/review-object.controller/index.js @@ -406,6 +406,90 @@ router.put('/review/:uuid', controller.updateReviewObjectByReviewUUID ) +// Approve a review object +router.put('/review/:uuid/approve', + /* + #swagger.tags = ['Review Object'] + #swagger.operationId = 'approveReviewObject' + #swagger.summary = "Approves a review object and applies changes to the organization (accessible to Secretariat only)" + #swagger.description = " +

Access Control

+

User must belong to an organization with the Secretariat role

" + #swagger.parameters['uuid'] = { description: 'The UUID of the review object' } + #swagger.parameters['$ref'] = [ + '#/components/parameters/apiEntityHeader', + '#/components/parameters/apiUserHeader', + '#/components/parameters/apiSecretHeader' + ] + #swagger.requestBody = { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + description: 'Optional override data to apply instead of the review object data' + } + } + } + } + #swagger.responses[200] = { + description: 'Returns the updated organization', + content: { + "application/json": { + schema: { + type: 'object', + description: 'The updated organization object' + } + } + } + } + #swagger.responses[400] = { + description: 'Bad Request', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/bad-request.json' } + } + } + } + #swagger.responses[401] = { + description: 'Not Authenticated', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/generic.json' } + } + } + } + #swagger.responses[403] = { + description: 'Forbidden', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/generic.json' } + } + } + } + #swagger.responses[404] = { + description: 'Not Found', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/generic.json' } + } + } + } + #swagger.responses[500] = { + description: 'Internal Server Error', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/generic.json' } + } + } + } + */ + mw.useRegistry(), + mw.validateUser, + mw.onlySecretariat, + controller.approveReviewObject +) + // Reject a review object router.put('/review/:uuid/reject', /* @@ -478,4 +562,78 @@ router.put('/review/:uuid/reject', controller.rejectReviewObject ) +// Create a review object +router.post('/review/org/', + /* + #swagger.tags = ['Review Object'] + #swagger.operationId = 'createReviewObject' + #swagger.summary = "Creates a new review object (accessible to Secretariat only)" + #swagger.description = " +

Access Control

+

User must belong to an organization with the Secretariat role

" + #swagger.parameters['$ref'] = [ + '#/components/parameters/apiEntityHeader', + '#/components/parameters/apiUserHeader', + '#/components/parameters/apiSecretHeader' + ] + #swagger.requestBody = { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + description: 'The review object data' + } + } + } + } + #swagger.responses[200] = { + description: 'Returns the created review object', + content: { + "application/json": { + schema: { + $ref: '../schemas/review/review.json' + } + } + } + } + #swagger.responses[400] = { + description: 'Bad Request', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/bad-request.json' } + } + } + } + #swagger.responses[401] = { + description: 'Not Authenticated', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/generic.json' } + } + } + } + #swagger.responses[403] = { + description: 'Forbidden', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/generic.json' } + } + } + } + #swagger.responses[500] = { + description: 'Internal Server Error', + content: { + "application/json": { + schema: { $ref: '../schemas/errors/generic.json' } + } + } + } + */ + mw.useRegistry(), + mw.validateUser, + mw.onlySecretariat, + controller.createReviewObject +) + module.exports = router From 32cc9ebe532a83bc2bf2c60f180f1019cdcc86d5 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 10:44:51 -0400 Subject: [PATCH 07/20] To keep some tests working correctly, we will keep the endpoints, but remove them from swagger --- src/controller/review-object.controller/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/controller/review-object.controller/index.js b/src/controller/review-object.controller/index.js index 702da7529..daf8a7a74 100644 --- a/src/controller/review-object.controller/index.js +++ b/src/controller/review-object.controller/index.js @@ -411,6 +411,7 @@ router.put('/review/:uuid/approve', /* #swagger.tags = ['Review Object'] #swagger.operationId = 'approveReviewObject' + #swagger.ignore = true #swagger.summary = "Approves a review object and applies changes to the organization (accessible to Secretariat only)" #swagger.description = "

Access Control

@@ -567,6 +568,7 @@ router.post('/review/org/', /* #swagger.tags = ['Review Object'] #swagger.operationId = 'createReviewObject' + #swagger.ignore = true #swagger.summary = "Creates a new review object (accessible to Secretariat only)" #swagger.description = "

Access Control

From 9eab687b61fa76154e414e38a209795e0fcee69e Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 10:45:56 -0400 Subject: [PATCH 08/20] Removing stuff from swagger --- api-docs/openapi.json | 188 ------------------------- src/repositories/baseUserRepository.js | 49 +++++-- 2 files changed, 35 insertions(+), 202 deletions(-) diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 5fd0bee55..2440a87ab 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -5398,110 +5398,6 @@ } } }, - "/review/{uuid}/approve": { - "put": { - "tags": [ - "Review Object" - ], - "summary": "Approves a review object and applies changes to the organization (accessible to Secretariat only)", - "description": "

Access Control

User must belong to an organization with the Secretariat role

", - "operationId": "approveReviewObject", - "parameters": [ - { - "name": "uuid", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "The UUID of the review object" - }, - { - "$ref": "#/components/parameters/apiEntityHeader" - }, - { - "$ref": "#/components/parameters/apiUserHeader" - }, - { - "$ref": "#/components/parameters/apiSecretHeader" - } - ], - "responses": { - "200": { - "description": "Returns the updated organization", - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "The updated organization object" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/bad-request.json" - } - } - } - }, - "401": { - "description": "Not Authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/generic.json" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/generic.json" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/generic.json" - } - } - } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/generic.json" - } - } - } - } - }, - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "Optional override data to apply instead of the review object data" - } - } - } - } - } - }, "/review/{uuid}/reject": { "put": { "tags": [ @@ -5593,90 +5489,6 @@ } } } - }, - "/review/org/": { - "post": { - "tags": [ - "Review Object" - ], - "summary": "Creates a new review object (accessible to Secretariat only)", - "description": "

Access Control

User must belong to an organization with the Secretariat role

", - "operationId": "createReviewObject", - "parameters": [ - { - "$ref": "#/components/parameters/apiEntityHeader" - }, - { - "$ref": "#/components/parameters/apiUserHeader" - }, - { - "$ref": "#/components/parameters/apiSecretHeader" - } - ], - "responses": { - "200": { - "description": "Returns the created review object", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/review/review.json" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/bad-request.json" - } - } - } - }, - "401": { - "description": "Not Authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/generic.json" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/generic.json" - } - } - } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/json": { - "schema": { - "$ref": "../schemas/errors/generic.json" - } - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "The review object data" - } - } - } - } - } } }, "components": { diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index d328161cc..120e83658 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -197,9 +197,11 @@ class BaseUserRepository extends BaseRepository { // Remove user from any organization’s users and admins arrays const orgs = await BaseOrgModel.find({ $or: [{ users: uuid }, { admins: uuid }] }) for (const org of orgs) { - org.users = org.users.filter(u => u !== uuid) + if (Array.isArray(org.users)) { + org.users.pull(uuid) + } if (Array.isArray(org.admins)) { - org.admins = org.admins.filter(a => a !== uuid) + org.admins.pull(uuid) } await org.save(options) } @@ -430,13 +432,17 @@ class BaseUserRepository extends BaseRepository { const rolesToAdd = _.flattenDeep(_.compact(_.get(incomingParameters, 'active_roles.add'))) const rolesToRemove = _.flattenDeep(_.compact(_.get(incomingParameters, 'active_roles.remove'))) if (rolesToRemove.includes('ADMIN')) { - const filteredUuids = registryOrg.admins.filter(uuid => uuid !== registryUser.UUID) - registryOrg.admins = filteredUuids + if (Array.isArray(registryOrg.admins)) { + registryOrg.admins.pull(registryUser.UUID) + } } if (rolesToAdd.includes('ADMIN') && !incomingParameters?.org_short_name) { // Use the already fetched registryOrg instead of querying again - registryOrg.admins = [...new Set([...(registryOrg.admins || []), registryUser.UUID])] + if (!Array.isArray(registryOrg.admins)) { + registryOrg.admins = [] + } + registryOrg.admins.addToSet(registryUser.UUID) } const initialRoles = legacyUser.authority?.active_roles ?? [] @@ -446,17 +452,24 @@ class BaseUserRepository extends BaseRepository { if (incomingParameters?.org_short_name) { // Remove us from the old users Array - const filteredUuids = registryOrg.users.filter(uuid => uuid !== registryUser.UUID) - registryOrg.users = filteredUuids + if (Array.isArray(registryOrg.users)) { + registryOrg.users.pull(registryUser.UUID) + } if (registryOrg.admins && registryOrg.admins.includes(registryUser.UUID)) { - registryOrg.admins = registryOrg.admins.filter(uuid => uuid !== registryUser.UUID) + registryOrg.admins.pull(registryUser.UUID) } // Add us to the new org (this is a genuine cross-org migration, so we must fetch the new org) const newOrg = await baseOrgRepository.getOrgObject(incomingParameters.org_short_name) - newOrg.users = [...new Set([...newOrg.users, registryUser.UUID])] + if (!Array.isArray(newOrg.users)) { + newOrg.users = [] + } + newOrg.users.addToSet(registryUser.UUID) if (registryUser.role.includes('ADMIN')) { - newOrg.admins = [...new Set([...(newOrg.admins || []), registryUser.UUID])] + if (!Array.isArray(newOrg.admins)) { + newOrg.admins = [] + } + newOrg.admins.addToSet(registryUser.UUID) } legacyUser.org_UUID = newOrg.UUID @@ -551,21 +564,29 @@ class BaseUserRepository extends BaseRepository { } // 1. Remove user from old org's users list - currentOrg.users = currentOrg.users.filter(u => u !== identifier) + if (Array.isArray(currentOrg.users)) { + currentOrg.users.pull(identifier) + } // 2. Remove user from old org's admins list (if present) if (currentOrg.admins && currentOrg.admins.includes(identifier)) { - currentOrg.admins = currentOrg.admins.filter(a => a !== identifier) + currentOrg.admins.pull(identifier) } // 3. Add user to new org's users list - newOrg.users = [...new Set([...newOrg.users, identifier])] + if (!Array.isArray(newOrg.users)) { + newOrg.users = [] + } + newOrg.users.addToSet(identifier) // 4. Add user to new org's admins list (if they are an admin) const isAdmin = updatedRegistryUser.role === 'ADMIN' || (updatedLegacyUser.authority && updatedLegacyUser.authority.active_roles && updatedLegacyUser.authority.active_roles.includes('ADMIN')) if (isAdmin) { - newOrg.admins = [...new Set([...(newOrg.admins || []), identifier])] + if (!Array.isArray(newOrg.admins)) { + newOrg.admins = [] + } + newOrg.admins.addToSet(identifier) } // 5. Update user's org_UUID From b2de8eaf44a559d1fb2d78bef4a11ab676cb811c Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 11:05:28 -0400 Subject: [PATCH 09/20] added a transaction and an await, the lock will now be detected if two writes try at once --- src/repositories/baseUserRepository.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index 120e83658..2df8b77a4 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -375,7 +375,7 @@ class BaseUserRepository extends BaseRepository { const registryUserToSave = new RegistryUser(registryObjectRaw) registryObject = await registryUserToSave.save(options) - baseOrgRepository.addUserToOrg(orgShortName, incomingUser.UUID, (incomingUser.role === 'ADMIN' || incomingUser.authority?.active_roles?.includes('ADMIN'))) + await baseOrgRepository.addUserToOrg(orgShortName, incomingUser.UUID, (incomingUser.role === 'ADMIN' || incomingUser.authority?.active_roles?.includes('ADMIN')), options) // We now have to make sure the user is added to the ORG's user array await legacyUserRepo.updateByUserNameAndOrgUUID(incomingUser.username, existingOrg.UUID, legacyObjectRaw, { ...options, upsert: true }) From 546116d28cb3f396ec0edb69e0e301033c28129c Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 11:09:22 -0400 Subject: [PATCH 10/20] linting fixes --- src/controller/org.controller/org.controller.js | 4 ---- test/integration-tests/registry-org/registryOrgCRUDTest.js | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/controller/org.controller/org.controller.js b/src/controller/org.controller/org.controller.js index 7bea3aebf..0a1772d4f 100644 --- a/src/controller/org.controller/org.controller.js +++ b/src/controller/org.controller/org.controller.js @@ -291,8 +291,6 @@ async function createOrg (req, res, next) { await session.abortTransaction() return res.status(400).json(error.aliasCollision(collisionString)) } - - const userRepo = req.ctx.repositories.getBaseUserRepository() const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org, { session }) const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) @@ -386,8 +384,6 @@ async function updateOrg (req, res, next) { await session.abortTransaction() return res.status(400).json(error.aliasCollision(collisionString)) } - - const userRepo = req.ctx.repositories.getBaseUserRepository() const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) const isSecretariat = await orgRepository.isSecretariatByShortName(req.ctx.org, { session }) diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 052bd756c..ebb8e66e5 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -485,7 +485,7 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.message).to.equal("The organization cannot be renamed as 'mitre' because this shortname is used by another organization.") }) }) - it("Fails to update a registry organization with an alias that collides with an existing short_name", async () => { + it('Fails to update a registry organization with an alias that collides with an existing short_name', async () => { await chai.request(app) .put('/api/registry/org/registry_org_test') .set(secretariatHeaders) From 2f2d69d672016eea19434581f7d5a9d1052eb551 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 11:24:10 -0400 Subject: [PATCH 11/20] Fixing unit tests --- src/controller/org.controller/org.controller.js | 1 + test/unit-tests/org/orgCreateADPTest.js | 1 + test/unit-tests/org/orgCreateTest.js | 1 + test/unit-tests/org/orgUpdateTest.js | 12 ++++++++++++ test/unit-tests/user/userCreateTest.js | 1 + 5 files changed, 16 insertions(+) diff --git a/src/controller/org.controller/org.controller.js b/src/controller/org.controller/org.controller.js index 0a1772d4f..571887f55 100644 --- a/src/controller/org.controller/org.controller.js +++ b/src/controller/org.controller/org.controller.js @@ -384,6 +384,7 @@ async function updateOrg (req, res, next) { await session.abortTransaction() return res.status(400).json(error.aliasCollision(collisionString)) } + const userRepo = req.ctx.repositories.getBaseUserRepository() const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) const isSecretariat = await orgRepository.isSecretariatByShortName(req.ctx.org, { session }) diff --git a/test/unit-tests/org/orgCreateADPTest.js b/test/unit-tests/org/orgCreateADPTest.js index 51c352890..03849363a 100644 --- a/test/unit-tests/org/orgCreateADPTest.js +++ b/test/unit-tests/org/orgCreateADPTest.js @@ -76,6 +76,7 @@ describe('Testing creating orgs with the ADP role', () => { // Stub UUID getters to resolve with fake UUIDs sinon.stub(regOrgRepo, 'getOrgUUID').resolves('org-uuid-123') sinon.stub(userRegistryRepo, 'getUserUUID').resolves('user-uuid-123') + sinon.stub(regOrgRepo, 'checkAliasCollisions').resolves(null) }) afterEach(() => { diff --git a/test/unit-tests/org/orgCreateTest.js b/test/unit-tests/org/orgCreateTest.js index 70a83f0bf..946d9986b 100644 --- a/test/unit-tests/org/orgCreateTest.js +++ b/test/unit-tests/org/orgCreateTest.js @@ -137,6 +137,7 @@ describe('Testing the ORG_CREATE_SINGLE controller', () => { baseUserRepo = new BaseUserRepository() getBaseUserRepository = sinon.stub().returns(baseUserRepo) sinon.stub(BaseUserRepository.prototype, 'findUserByUUID').resolves({ username: 'test_user' }) + sinon.stub(baseOrgRepo, 'checkAliasCollisions').resolves(null) }) // Restore all stubs after each test diff --git a/test/unit-tests/org/orgUpdateTest.js b/test/unit-tests/org/orgUpdateTest.js index e53f8cbd3..f34bf7c85 100644 --- a/test/unit-tests/org/orgUpdateTest.js +++ b/test/unit-tests/org/orgUpdateTest.js @@ -64,6 +64,10 @@ class OrgUpdatedAddingRole { async orgExists () { return true } + + async checkAliasCollisions () { + return null + } } class OrgUpdatedRemovingRole { @@ -97,6 +101,10 @@ class OrgUpdatedRemovingRole { async getOrgUUID () { return null } + + async checkAliasCollisions () { + return null + } } // eslint-disable-next-line mocha/no-skipped-tests @@ -352,6 +360,10 @@ describe('Testing the PUT /org/:shortname endpoint in Org Controller', () => { async aggregate () { return [orgFixtures.existentOrg] } + + async checkAliasCollisions () { + return null + } } app.route('/org-not-updated-no-query-parameters/:shortname') diff --git a/test/unit-tests/user/userCreateTest.js b/test/unit-tests/user/userCreateTest.js index 35e16b247..3db6ce57e 100644 --- a/test/unit-tests/user/userCreateTest.js +++ b/test/unit-tests/user/userCreateTest.js @@ -107,6 +107,7 @@ describe('Testing the POST /org/:shortname/user endpoint in Org Controller', () sinon.stub(baseOrgRepo, 'getOrgUUID').resolves(true) sinon.stub(argon2, 'hash').resolves('hashedPassword') sinon.stub(BaseOrgRepository.prototype, 'findOneByShortName').resolves(fakeOrgMongooseDocument) + sinon.stub(BaseOrgRepository.prototype, 'addUserToOrg').resolves() sinon.stub(baseUserRepo, 'findUsersByOrgShortname').resolves([fakeUserMongooseDocument]) sinon.stub(RegistryUserModel.prototype, 'save').resolves(fakeBaseUserSavedObject) // stub the prototype since createUser in baseUserRepository creates a new internal instance of the legacy UserRepository From f4bcd59bee39c10444a1c04a892a8763ba19853d Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 14:07:26 -0400 Subject: [PATCH 12/20] We now check to see if the user can read the review --- .../review-object.controller.js | 16 ++++++++++++++ .../review-object/reviewObjectTest.js | 22 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/controller/review-object.controller/review-object.controller.js b/src/controller/review-object.controller/review-object.controller.js index 7b4556d29..7de67c58d 100644 --- a/src/controller/review-object.controller/review-object.controller.js +++ b/src/controller/review-object.controller/review-object.controller.js @@ -38,6 +38,18 @@ async function getReviewObjectByUUID (req, res, next) { const isSecretariat = await orgRepo.isSecretariatByShortName(req.ctx.org) const UUID = req.params.uuid const value = await repo.findOneByUUIDWithConversation(UUID, isSecretariat) + + if (!value) { + return res.status(404).json({ message: 'Review object not found' }) + } + + if (!isSecretariat) { + const orgUUID = await orgRepo.getOrgUUID(req.ctx.org) + if (value.target_object_uuid !== orgUUID) { + return res.status(403).json({ error: 'NOT_SAME_ORG_OR_SECRETARIAT', message: 'This information can only be viewed by the users of the same organization or the Secretariat.' }) + } + } + return res.status(200).json(value) } @@ -197,6 +209,10 @@ async function getReviewHistoryByOrgShortNamePaginated (req, res, next) { return res.status(404).json(error.orgDnePathParam(orgShortName)) } + if (!isSecretariat && req.ctx.org !== orgShortName) { + return res.status(403).json({ error: 'NOT_SAME_ORG_OR_SECRETARIAT', message: 'This information can only be viewed by the users of the same organization or the Secretariat.' }) + } + if (req.TEST_PAGINATOR_LIMIT) { CONSTANTS.PAGINATOR_OPTIONS.limit = req.TEST_PAGINATOR_LIMIT } diff --git a/test/integration-tests/review-object/reviewObjectTest.js b/test/integration-tests/review-object/reviewObjectTest.js index 01e49f705..3d9c10e46 100644 --- a/test/integration-tests/review-object/reviewObjectTest.js +++ b/test/integration-tests/review-object/reviewObjectTest.js @@ -479,8 +479,8 @@ describe('Review Object Controller Integration Tests', () => { .request(app) .get(`/api/review/byUUID/${fakeUUID}`) .set({ ...constants.headers }) - expect(res).to.have.status(200) - expect(res.body).to.be.null + expect(res).to.have.status(404) + expect(res.body.message).to.equal('Review object not found') }) it('Returns 404 for review history of non-existent organization', async () => { @@ -542,5 +542,23 @@ describe('Review Object Controller Integration Tests', () => { .set({ ...constants.nonSecretariatUserHeaders }) expect(res).to.have.status(403) }) + + it('Non-secretariat user of another org cannot access review object by UUID', async () => { + const res = await chai + .request(app) + .get(`/api/review/byUUID/${reviewUUID}`) + .set({ ...constants.nonSecretariatUserHeaders2 }) + expect(res).to.have.status(403) + expect(res.body.error).to.equal('NOT_SAME_ORG_OR_SECRETARIAT') + }) + + it('Non-secretariat user of another org cannot access review history', async () => { + const res = await chai + .request(app) + .get(`/api/review/org/${constants.testRegistryOrg2.short_name}/reviews`) + .set({ ...constants.nonSecretariatUserHeaders2 }) + expect(res).to.have.status(403) + expect(res.body.error).to.equal('NOT_SAME_ORG_OR_SECRETARIAT') + }) }) }) From ec8291094e742f6220467a6cee262dcd02008424 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 14:17:11 -0400 Subject: [PATCH 13/20] fixing some unit tests --- .../review-object/review-object.controller.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/unit-tests/review-object/review-object.controller.test.js b/test/unit-tests/review-object/review-object.controller.test.js index 45f4f779c..ece2d91ca 100644 --- a/test/unit-tests/review-object/review-object.controller.test.js +++ b/test/unit-tests/review-object/review-object.controller.test.js @@ -117,13 +117,13 @@ describe('Review Object Controller', function () { expect(repoStub.findOneByUUIDWithConversation.calledWith(uuid, false)).to.be.true }) - it('should return null when review object not found', async () => { + it('should return 404 when review object not found', async () => { const uuid = 'nonexistent-uuid' req.params.uuid = uuid repoStub.findOneByUUIDWithConversation = sinon.stub().resolves(null) await controller.getReviewObjectByUUID(req, res, next) - expect(res.status.calledWith(200)).to.be.true - expect(res.json.calledWith(null)).to.be.true + expect(res.status.calledWith(404)).to.be.true + expect(res.json.calledWith({ message: 'Review object not found' })).to.be.true }) }) @@ -348,6 +348,7 @@ describe('Review Object Controller', function () { it('should pass isSecretariat flag to repository', async () => { orgRepoStub.orgExists = sinon.stub().resolves(true) orgRepoStub.isSecretariatByShortName = sinon.stub().resolves(false) + req.ctx.org = orgShortName repoStub.getReviewHistoryByOrgShortNamePaginated = sinon.stub().resolves({ reviewObjects: [], totalDocs: 0 }) await controller.getReviewHistoryByOrgShortNamePaginated(req, res, next) const callArgs = repoStub.getReviewHistoryByOrgShortNamePaginated.getCall(0).args From 0e78d3512d6c2b70656203b492531b6133622d9d Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 4 May 2026 14:58:04 -0400 Subject: [PATCH 14/20] changing users and user roles should now create audit logs --- .../org.controller/org.controller.js | 6 +- .../registry-org.controller.js | 3 +- .../registry-user.controller.js | 15 ++-- src/repositories/baseOrgRepository.js | 32 ++++++--- src/repositories/baseUserRepository.js | 32 +++++++-- .../audit/registryOrgCreatesAuditTest.js | 72 +++++++++++++++++++ test/unit-tests/user/userCreateTest.js | 1 + 7 files changed, 140 insertions(+), 21 deletions(-) diff --git a/src/controller/org.controller/org.controller.js b/src/controller/org.controller/org.controller.js index 571887f55..a1bc0cf58 100644 --- a/src/controller/org.controller/org.controller.js +++ b/src/controller/org.controller/org.controller.js @@ -494,7 +494,8 @@ async function createUser (req, res, next) { return res.status(400).json(error.userLimitReached()) } - returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }, !!req.useRegistry) + const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }, !!req.useRegistry, requestingUserUUID) await session.commitTransaction() } catch (error) { await session.abortTransaction() @@ -669,7 +670,8 @@ async function updateUser (req, res, next) { } } - const payload = await userRepo.updateUser(usernameParams, shortNameParams, queryParametersJson, { session }, !!req.useRegistry) + const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const payload = await userRepo.updateUser(usernameParams, shortNameParams, queryParametersJson, { session }, !!req.useRegistry, requestingUserUUID) await session.commitTransaction() return res.status(200).json({ message: `${usernameParams} was successfully updated.`, updated: payload }) } catch (err) { diff --git a/src/controller/registry-org.controller/registry-org.controller.js b/src/controller/registry-org.controller/registry-org.controller.js index 5f77139c2..30ed3670f 100644 --- a/src/controller/registry-org.controller/registry-org.controller.js +++ b/src/controller/registry-org.controller/registry-org.controller.js @@ -614,7 +614,8 @@ async function createUserByOrg (req, res, next) { return res.status(400).json(error.userLimitReached()) } - returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }, true) + const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }, true, requestingUserUUID) await session.commitTransaction() } catch (error) { await session.abortTransaction() diff --git a/src/controller/registry-user.controller/registry-user.controller.js b/src/controller/registry-user.controller/registry-user.controller.js index 3133c197e..4fabccc37 100644 --- a/src/controller/registry-user.controller/registry-user.controller.js +++ b/src/controller/registry-user.controller/registry-user.controller.js @@ -186,7 +186,8 @@ async function createUser (req, res, next) { return res.status(400).json(error.userLimitReached()) } - returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }) + const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }, true, requestingUserUUID) await session.commitTransaction() } catch (error) { await session.abortTransaction() @@ -351,8 +352,9 @@ async function updateUser (req, res, next) { } // UUID of the user will not change, lets get it before we write to avoid read after write issues. + const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) updatedUserUUID = await userRepo.getUserUUID(req.ctx.user, org.UUID) - updatedUser = await userRepo.updateUserFull(userToEdit.UUID, body, { session }) + updatedUser = await userRepo.updateUserFull(userToEdit.UUID, body, { session }, true, requestingUserUUID) await session.commitTransaction() } catch (error) { await session.abortTransaction() @@ -395,7 +397,8 @@ async function deleteUser (req, res, next) { return res.status(404).json(error.userDne(userUUID)) } - await userRepo.deleteUserByUUID(userUUID) + const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org) + await userRepo.deleteUserByUUID(userUUID, {}, requestingUserUUID) const payload = { action: 'delete_registry_user', @@ -462,7 +465,8 @@ async function grantRole (req, res, next) { try { session.startTransaction() - await orgRepo.addAdmin(orgShortName, targetUser.UUID, { session }) + const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + await orgRepo.addAdmin(orgShortName, targetUser.UUID, { session }, requestingUserUUID) await session.commitTransaction() } catch (error) { await session.abortTransaction() @@ -530,7 +534,8 @@ async function revokeRole (req, res, next) { try { session.startTransaction() - await orgRepo.removeAdmin(orgShortName, targetUser.UUID, { session }) + const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + await orgRepo.removeAdmin(orgShortName, targetUser.UUID, { session }, requestingUserUUID) await session.commitTransaction() } catch (error) { await session.abortTransaction() diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index de503ca12..d6c0ca724 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -258,7 +258,7 @@ class BaseOrgRepository extends BaseRepository { * @param {boolean} [isLegacyObject=false] - Unused parameter. * @returns {Promise} */ - async addUserToOrg (orgShortName, userUUID, isAdmin = false, options = {}, isLegacyObject = false) { + async addUserToOrg (orgShortName, userUUID, isAdmin = false, options = {}, isLegacyObject = false, requestingUserUUID = null) { const update = { $addToSet: { users: userUUID } } @@ -267,7 +267,11 @@ class BaseOrgRepository extends BaseRepository { update.$addToSet.admins = userUUID } - await BaseOrgModel.updateOne({ short_name: orgShortName }, update, options) + const originalOrg = await BaseOrgModel.findOneAndUpdate({ short_name: orgShortName }, update, options) + if (requestingUserUUID && originalOrg) { + const updatedOrg = await BaseOrgModel.findOne({ short_name: orgShortName }, null, options) + await createAuditLogEntry(updatedOrg, originalOrg.toObject(), requestingUserUUID, options) + } } /** @@ -279,18 +283,24 @@ class BaseOrgRepository extends BaseRepository { * @param {object} [options={}] - Optional settings for the repository query. * @returns {Promise} */ - async addAdmin (orgShortName, userUUID, options = {}) { + async addAdmin (orgShortName, userUUID, options = {}, requestingUserUUID = null) { const UserRepository = require('./userRepository') const legacyUserRepo = new UserRepository() - const executeOptions = { ...options, new: true } + const executeOptions = { ...options, new: false } - const updatedOrg = await BaseOrgModel.findOneAndUpdate( + const originalOrg = await BaseOrgModel.findOneAndUpdate( { short_name: orgShortName }, { $addToSet: { admins: userUUID } }, executeOptions ) + const updatedOrg = await BaseOrgModel.findOne({ short_name: orgShortName }, null, options) + + if (requestingUserUUID && originalOrg) { + await createAuditLogEntry(updatedOrg, originalOrg.toObject(), requestingUserUUID, options) + } + await legacyUserRepo.collection.findOneAndUpdate( { UUID: userUUID }, { $addToSet: { 'authority.active_roles': 'ADMIN' } }, @@ -309,18 +319,24 @@ class BaseOrgRepository extends BaseRepository { * @param {object} [options={}] - Optional settings for the repository query. * @returns {Promise} */ - async removeAdmin (orgShortName, userUUID, options = {}) { + async removeAdmin (orgShortName, userUUID, options = {}, requestingUserUUID = null) { const UserRepository = require('./userRepository') const legacyUserRepo = new UserRepository() - const executeOptions = { ...options, new: true } + const executeOptions = { ...options, new: false } - const updatedOrg = await BaseOrgModel.findOneAndUpdate( + const originalOrg = await BaseOrgModel.findOneAndUpdate( { short_name: orgShortName }, { $pull: { admins: userUUID } }, executeOptions ) + const updatedOrg = await BaseOrgModel.findOne({ short_name: orgShortName }, null, options) + + if (requestingUserUUID && originalOrg) { + await createAuditLogEntry(updatedOrg, originalOrg.toObject(), requestingUserUUID, options) + } + await legacyUserRepo.collection.findOneAndUpdate( { UUID: userUUID }, { $pull: { 'authority.active_roles': 'ADMIN' } }, diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index 2df8b77a4..720fae515 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -187,7 +187,7 @@ class BaseUserRepository extends BaseRepository { * @param {object} options - Mongoose options for the delete operations. * @returns {Promise} Number of deleted documents (should be 1 if successful). */ - async deleteUserByUUID (uuid, options = {}) { + async deleteUserByUUID (uuid, options = {}, requestingUserUUID = null) { // Delete from BaseUser collection const deleteResult = await BaseUser.deleteOne({ UUID: uuid }, options) @@ -195,8 +195,10 @@ class BaseUserRepository extends BaseRepository { await RegistryUser.deleteOne({ UUID: uuid }, options) // Remove user from any organization’s users and admins arrays + const { createAuditLogEntry } = require('./baseOrgRepositoryHelpers') const orgs = await BaseOrgModel.find({ $or: [{ users: uuid }, { admins: uuid }] }) for (const org of orgs) { + const originalOrg = org.toObject() if (Array.isArray(org.users)) { org.users.pull(uuid) } @@ -204,6 +206,9 @@ class BaseUserRepository extends BaseRepository { org.admins.pull(uuid) } await org.save(options) + if (requestingUserUUID) { + await createAuditLogEntry(org, originalOrg, requestingUserUUID, options) + } } return deleteResult.deletedCount @@ -334,7 +339,7 @@ class BaseUserRepository extends BaseRepository { * @param {boolean} [isRegistryObject=true] - If false, accepts legacy user object. * @returns {Promise} The created user object (registry or legacy format). */ - async createUser (orgShortName, incomingUser, options = {}, isRegistryObject = true) { + async createUser (orgShortName, incomingUser, options = {}, isRegistryObject = true, requestingUserUUID = null) { const { deepRemoveEmpty } = require('../utils/utils') // TO-DO: org_UUID is not necessarily the shortname. Is this info lost during conversion? let legacyObjectRaw = null @@ -375,7 +380,7 @@ class BaseUserRepository extends BaseRepository { const registryUserToSave = new RegistryUser(registryObjectRaw) registryObject = await registryUserToSave.save(options) - await baseOrgRepository.addUserToOrg(orgShortName, incomingUser.UUID, (incomingUser.role === 'ADMIN' || incomingUser.authority?.active_roles?.includes('ADMIN')), options) + await baseOrgRepository.addUserToOrg(orgShortName, incomingUser.UUID, (incomingUser.role === 'ADMIN' || incomingUser.authority?.active_roles?.includes('ADMIN')), options, false, requestingUserUUID) // We now have to make sure the user is added to the ORG's user array await legacyUserRepo.updateByUserNameAndOrgUUID(incomingUser.username, existingOrg.UUID, legacyObjectRaw, { ...options, upsert: true }) @@ -407,11 +412,13 @@ class BaseUserRepository extends BaseRepository { * @param {boolean} [isRegistryObject=true] - If false, returns a legacy user object. * @returns {Promise} The updated user object. */ - async updateUser (username, orgShortname, incomingParameters, options = {}, isRegistryObject = true) { + async updateUser (username, orgShortname, incomingParameters, options = {}, isRegistryObject = true, requestingUserUUID = null) { const { deepRemoveEmpty } = require('../utils/utils') const baseOrgRepository = new BaseOrgRepository() const legacyUserRepo = new UserRepository() + const { createAuditLogEntry } = require('./baseOrgRepositoryHelpers') const registryOrg = await baseOrgRepository.getOrgObject(orgShortname, false, options) + const originalRegistryOrg = registryOrg.toObject() const legacyUser = await legacyUserRepo.findOneByUserNameAndOrgUUID(username, registryOrg.UUID, null, options) const registryUser = await this.findOneByUsernameAndOrgShortname(username, orgShortname, options, true) // WE always want the registry user @@ -460,6 +467,7 @@ class BaseUserRepository extends BaseRepository { } // Add us to the new org (this is a genuine cross-org migration, so we must fetch the new org) const newOrg = await baseOrgRepository.getOrgObject(incomingParameters.org_short_name) + const originalNewOrg = newOrg.toObject() if (!Array.isArray(newOrg.users)) { newOrg.users = [] } @@ -474,11 +482,17 @@ class BaseUserRepository extends BaseRepository { legacyUser.org_UUID = newOrg.UUID await newOrg.save(options) + if (requestingUserUUID) { + await createAuditLogEntry(newOrg, originalNewOrg, requestingUserUUID, options) + } } delete registryUser.role // Single unified save for the primary org at the end await registryOrg.save(options) + if (requestingUserUUID) { + await createAuditLogEntry(registryOrg, originalRegistryOrg, requestingUserUUID, options) + } await legacyUser.save(options) await registryUser.save(options) @@ -512,7 +526,7 @@ class BaseUserRepository extends BaseRepository { * @param {boolean} [isRegistryObject=true] - If false, accepts/returns legacy format. * @returns {Promise} The updated user object. */ - async updateUserFull (identifier, incomingUser, options = {}, isRegistryObject = true) { + async updateUserFull (identifier, incomingUser, options = {}, isRegistryObject = true, requestingUserUUID = null) { const legacyUserRepo = new UserRepository() // Find registry user by UUID @@ -555,9 +569,12 @@ class BaseUserRepository extends BaseRepository { try { if (incomingUser.org_short_name) { const baseOrgRepository = new BaseOrgRepository() + const { createAuditLogEntry } = require('./baseOrgRepositoryHelpers') const currentOrgUUID = legacyUser.org_UUID const currentOrg = await baseOrgRepository.findOneByUUID(currentOrgUUID) + const originalCurrentOrg = currentOrg.toObject() const newOrg = await baseOrgRepository.findOneByShortName(incomingUser.org_short_name) + const originalNewOrg = newOrg.toObject() if (!newOrg) { throw new Error(`Organization ${incomingUser.org_short_name} not found`) @@ -595,6 +612,11 @@ class BaseUserRepository extends BaseRepository { // Save org changes await currentOrg.save(options) await newOrg.save(options) + + if (requestingUserUUID) { + await createAuditLogEntry(currentOrg, originalCurrentOrg, requestingUserUUID, options) + await createAuditLogEntry(newOrg, originalNewOrg, requestingUserUUID, options) + } } await updatedLegacyUser.save(options) diff --git a/test/integration-tests/audit/registryOrgCreatesAuditTest.js b/test/integration-tests/audit/registryOrgCreatesAuditTest.js index 92adb7849..d9777c1d8 100644 --- a/test/integration-tests/audit/registryOrgCreatesAuditTest.js +++ b/test/integration-tests/audit/registryOrgCreatesAuditTest.js @@ -249,4 +249,76 @@ describe('Create and Update Audit Collection with Org Endpoints', () => { // Should have 2 entries: initial creation of current org object + new update expect(auditResCreation.body.history).to.have.lengthOf(2) }) + + it('Should add audit entry when users or admins are modified via user endpoints', async () => { + const testOrg = await createTestOrg({ + hard_quota: 1500, + authority: ['CNA'] + }) + + // 1. Create User + const username = `user_${uuidv4().slice(0, 8)}` + const createUserRes = await chai.request(app) + .post(`/api/registry/org/${testOrg.shortName}/user`) + .set(secretariatHeaders) + .send({ + username: username, + name: { + first: 'Test', + last: 'User' + } + }) + + expect(createUserRes).to.have.status(200) + + // Check audit history + const auditRes1 = await chai.request(app) + .get(`/api/audit/org/${testOrg.uuid}`) + .set(constants.headers) + + expect(auditRes1.body.history).to.have.lengthOf(2) // 1. Org creation, 2. User creation + + const historyAfterCreate = auditRes1.body.history[1].audit_object + expect(historyAfterCreate.users).to.be.an('array').that.includes(createUserRes.body.created.UUID) + expect(historyAfterCreate.admins).to.be.an('array').that.does.not.include(createUserRes.body.created.UUID) + + // 2. Grant Admin Role + const grantRoleRes = await chai.request(app) + .post(`/api/registry/org/${testOrg.shortName}/user/${username}/grant-role`) + .set(secretariatHeaders) + .send({ + role: 'ADMIN' + }) + + expect(grantRoleRes).to.have.status(200) + + // Check audit history again + const auditRes2 = await chai.request(app) + .get(`/api/audit/org/${testOrg.uuid}`) + .set(constants.headers) + + expect(auditRes2.body.history).to.have.lengthOf(3) // 3. Admin role granted + + const historyAfterGrant = auditRes2.body.history[2].audit_object + expect(historyAfterGrant.admins).to.be.an('array').that.includes(createUserRes.body.created.UUID) + + // 3. Revoke Admin Role + const revokeRoleRes = await chai.request(app) + .post(`/api/registry/org/${testOrg.shortName}/user/${username}/revoke-role`) + .set(secretariatHeaders) + .send({ + role: 'ADMIN' + }) + expect(revokeRoleRes).to.have.status(200) + + // Check audit history again + const auditRes3 = await chai.request(app) + .get(`/api/audit/org/${testOrg.uuid}`) + .set(constants.headers) + + expect(auditRes3.body.history).to.have.lengthOf(4) // 4. Admin role revoked + + const historyAfterRevoke = auditRes3.body.history[3].audit_object + expect(historyAfterRevoke.admins).to.be.an('array').that.does.not.include(createUserRes.body.created.UUID) + }) }) diff --git a/test/unit-tests/user/userCreateTest.js b/test/unit-tests/user/userCreateTest.js index 3db6ce57e..281539f94 100644 --- a/test/unit-tests/user/userCreateTest.js +++ b/test/unit-tests/user/userCreateTest.js @@ -112,6 +112,7 @@ describe('Testing the POST /org/:shortname/user endpoint in Org Controller', () sinon.stub(RegistryUserModel.prototype, 'save').resolves(fakeBaseUserSavedObject) // stub the prototype since createUser in baseUserRepository creates a new internal instance of the legacy UserRepository sinon.stub(UserRepository.prototype, 'updateByUserNameAndOrgUUID').resolves(fakeUserMongooseDocument) + sinon.stub(baseUserRepo, 'getUserUUID').resolves('mock-uuid') await USER_CREATE_SINGLE(req, res, next) expect(status.args[0][0]).to.equal(200) From f39697cb3e74772eec3a3bea23d6935d5a3c6dcb Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 5 May 2026 09:43:54 -0400 Subject: [PATCH 15/20] Removing some items from the audit logs --- src/repositories/baseOrgRepositoryHelpers.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/repositories/baseOrgRepositoryHelpers.js b/src/repositories/baseOrgRepositoryHelpers.js index 0bac9a580..869d619c7 100644 --- a/src/repositories/baseOrgRepositoryHelpers.js +++ b/src/repositories/baseOrgRepositoryHelpers.js @@ -200,22 +200,24 @@ async function createAuditLogEntry (registryOrg, originalRegistryOrgObject, requ try { const AuditRepository = require('./auditRepository') const auditRepo = new AuditRepository() - await auditRepo.seedAuditHistoryForOrg( - registryOrg.UUID, - originalRegistryOrgObject, - requestingUserUUID, - { ...options, upsert: true } - ) + const beforeUpdateObject = originalRegistryOrgObject const afterUpdateObject = registryOrg.toObject() const cleanBefore = _.omit(beforeUpdateObject, ['_id', '__v', '__t', 'createdAt', 'updatedAt']) const cleanAfter = _.omit(afterUpdateObject, ['_id', '__v', '__t', 'createdAt', 'updatedAt']) + await auditRepo.seedAuditHistoryForOrg( + registryOrg.UUID, + cleanBefore, + requestingUserUUID, + { ...options, upsert: true } + ) + if (!_.isEqual(cleanBefore, cleanAfter)) { await auditRepo.appendToAuditHistoryForOrg( registryOrg.UUID, - registryOrg.toObject(), + cleanAfter, requestingUserUUID, { ...options, upsert: true } ) From e34066c7b693aaa3048596f305b87396c5896f9c Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 5 May 2026 15:39:00 -0400 Subject: [PATCH 16/20] Admin users can now add conversations --- .../conversation.controller/conversation.controller.js | 4 +++- src/controller/conversation.controller/index.js | 2 +- src/repositories/conversationRepository.js | 10 +++++++++- .../integration-tests/conversation/conversationTest.js | 10 ---------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/controller/conversation.controller/conversation.controller.js b/src/controller/conversation.controller/conversation.controller.js index 1b4dbb3ad..0813c0955 100644 --- a/src/controller/conversation.controller/conversation.controller.js +++ b/src/controller/conversation.controller/conversation.controller.js @@ -37,6 +37,7 @@ async function createConversationForTargetUUID (req, res, next) { const repo = req.ctx.repositories.getConversationRepository() const userRepo = req.ctx.repositories.getBaseUserRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() const requesterOrg = req.ctx.org const requesterUsername = req.ctx.user const targetUUID = req.params.uuid @@ -48,7 +49,8 @@ async function createConversationForTargetUUID (req, res, next) { return res.status(400).json(error.invalidConversationObject()) } - const result = await repo.createConversation(targetUUID, body, user, true, { session }) + const isSecretariat = await orgRepo.isSecretariatByShortName(req.ctx.org) + const result = await repo.createConversation(targetUUID, body, user, isSecretariat, { session }) await session.commitTransaction() if (!result) { return res.status(500).json({ message: 'Failed to create conversation' }) diff --git a/src/controller/conversation.controller/index.js b/src/controller/conversation.controller/index.js index 45eeeee53..ea2bf62ed 100644 --- a/src/controller/conversation.controller/index.js +++ b/src/controller/conversation.controller/index.js @@ -252,7 +252,7 @@ router.post('/conversation/target/:uuid', } */ mw.validateUser, - mw.onlySecretariat, + mw.onlySecretariatOrAdmin, param(['uuid']).isUUID(4), controller.createConversationForTargetUUID ) diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index c8a8d48cc..b4ef2e437 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -92,7 +92,15 @@ class ConversationRepository extends BaseRepository { } const newConversation = new ConversationModel(conversationObj) const result = await newConversation.save(options) - return result.toObject() + + const rawObject = result.toObject() + + delete rawObject._id + delete rawObject.__v + delete rawObject.previous_conversation_uuid + delete rawObject.next_conversation_uuid + + return rawObject } async editConversation (UUID, incomingParameters, options = {}) { diff --git a/test/integration-tests/conversation/conversationTest.js b/test/integration-tests/conversation/conversationTest.js index d2c5db976..8e14ea32e 100644 --- a/test/integration-tests/conversation/conversationTest.js +++ b/test/integration-tests/conversation/conversationTest.js @@ -54,11 +54,6 @@ describe('Testing Conversation endpoints', () => { expect(res.body).to.haveOwnProperty('target_uuid') expect(res.body.target_uuid).to.equal(orgUUID) - expect(res.body).to.haveOwnProperty('previous_conversation_uuid') - expect(res.body.previous_conversation_uuid).to.be.null - expect(res.body).to.haveOwnProperty('next_conversation_uuid') - expect(res.body.next_conversation_uuid).to.be.null - expect(res.body).to.haveOwnProperty('author_id') expect(res.body.author_id).to.equal(secUserUUID) @@ -110,11 +105,6 @@ describe('Testing Conversation endpoints', () => { expect(rootMessage).to.exist expect(rootMessage.previous_conversation_uuid).to.be.null expect(rootMessage.next_conversation_uuid).to.be.equal(secondUUID) - - expect(res.body).to.haveOwnProperty('previous_conversation_uuid') - expect(res.body.previous_conversation_uuid).to.be.equal(rootConvoUUID) - expect(res.body).to.haveOwnProperty('next_conversation_uuid') - expect(res.body.next_conversation_uuid).to.be.null }) it('Should get all conversations', async () => { await chai.request(app) From cf33007ee16d57a23df8a632c6632fd5be3fdcd1 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 5 May 2026 16:01:02 -0400 Subject: [PATCH 17/20] Hydration --- .../registry-org.controller.js | 2 +- src/repositories/baseOrgRepository.js | 19 +++++++++++++++++++ .../registry-org/registryOrgCRUDTest.js | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/controller/registry-org.controller/registry-org.controller.js b/src/controller/registry-org.controller/registry-org.controller.js index 30ed3670f..e66646d5a 100644 --- a/src/controller/registry-org.controller/registry-org.controller.js +++ b/src/controller/registry-org.controller/registry-org.controller.js @@ -97,7 +97,7 @@ async function getOrg (req, res, next) { // fetch conversation const conversation = await conversationRepo.getAllByTargetUUID(returnValue.UUID, isSecretariat) if (isSecretariat) { - returnValue.conversation = conversation?.length ? _.map(conversation, c => _.omit(c, ['__v', '_id', 'UUID', 'previous_conversation_uuid', 'next_conversation_uuid', 'target_uuid'])) : undefined + returnValue.conversation = conversation?.length ? _.map(conversation, c => _.omit(c, ['__v', '_id', 'previous_conversation_uuid', 'next_conversation_uuid', 'target_uuid'])) : undefined } else { returnValue.conversation = conversation?.length ? _.map(conversation, c => _.omit(c, ['__v', '_id', 'UUID', 'previous_conversation_uuid', 'next_conversation_uuid', 'target_uuid', 'visibility'])) : undefined } diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index d6c0ca724..9676de968 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -429,6 +429,25 @@ class BaseOrgRepository extends BaseRepository { result.reports_to = parentOrg.UUID } + const relatedOrgUUIDs = new Set() + if (Array.isArray(result.oversees)) { + result.oversees.forEach(uuid => relatedOrgUUIDs.add(uuid)) + } + if (result.reports_to) { + relatedOrgUUIDs.add(result.reports_to) + } + + if (relatedOrgUUIDs.size > 0) { + const relatedOrgs = await BaseOrgModel.find({ UUID: { $in: Array.from(relatedOrgUUIDs) } }).select('short_name long_name UUID').lean() + if (relatedOrgs.length > 0) { + result._relatedOrganizations = relatedOrgs.map(org => ({ + short_name: org.short_name, + long_name: org.long_name, + UUID: org.UUID + })) + } + } + return deepRemoveEmpty(result) } diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index ebb8e66e5..9bd0747bf 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -408,6 +408,22 @@ describe('Testing /registryOrg endpoints', () => { .then(res => { expect(res).to.have.status(200) expect(res.body).to.have.property('reports_to', createdOrg.UUID) + expect(res.body).to.have.property('_relatedOrganizations') + expect(res.body._relatedOrganizations).to.be.an('array').that.has.lengthOf(1) + expect(res.body._relatedOrganizations[0].UUID).to.equal(createdOrg.UUID) + expect(res.body._relatedOrganizations[0].short_name).to.equal(createdOrg.short_name) + }) + + // Assert that the main org also has _relatedOrganizations for the sub org it oversees + await chai.request(app) + .get(`/api/registry/org/${createdOrg.short_name}`) + .set(secretariatHeaders) + .then(res => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('_relatedOrganizations') + expect(res.body._relatedOrganizations).to.be.an('array').that.has.lengthOf(1) + expect(res.body._relatedOrganizations[0].UUID).to.equal(createdSubOrgUUID) + expect(res.body._relatedOrganizations[0].short_name).to.equal(subOrg.short_name) }) // Cleanup sub org From bbd272b71b8e71083c846b9616242fa6472ccf25 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 5 May 2026 16:26:00 -0400 Subject: [PATCH 18/20] we can't have duplicate shortnames --- src/repositories/baseOrgRepository.js | 21 +++++++++++++++---- .../registry-org/registryOrgCRUDTest.js | 15 +++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 9676de968..3b1afc328 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -198,7 +198,10 @@ class BaseOrgRepository extends BaseRepository { * @returns {Promise} True if the organization exists, false otherwise. */ async orgExists (shortName, options = {}, returnLegacyFormat = false) { - if (await this.findOneByShortName(shortName, options, returnLegacyFormat)) { + if (!shortName) return false + const query = { $expr: { $eq: [{ $toLower: '$short_name' }, shortName.toLowerCase()] } } + const exists = await BaseOrgModel.findOne(query, null, options) + if (exists) { return true } return false @@ -221,23 +224,33 @@ class BaseOrgRepository extends BaseRepository { return null } + const searchStringsLower = searchStrings.map(s => s.toLowerCase()) + const query = { $or: [ - { short_name: { $in: searchStrings } }, + { + $expr: { + $or: searchStringsLower.map(s => ({ + $eq: [{ $toLower: '$short_name' }, s] + })) + } + }, { long_name: { $in: searchStrings } }, { aliases: { $in: searchStrings } } ] } if (shortNameToExclude) { - query.short_name = { $ne: shortNameToExclude } + query.$and = [ + { $expr: { $ne: [{ $toLower: '$short_name' }, shortNameToExclude.toLowerCase()] } } + ] } const collisionOrg = await BaseOrgModel.findOne(query, 'short_name long_name aliases', options) if (collisionOrg) { // Determine which string collided for better error reporting for (const str of searchStrings) { - if (collisionOrg.short_name === str || collisionOrg.long_name === str || (collisionOrg.aliases && collisionOrg.aliases.includes(str))) { + if (collisionOrg.short_name?.toLowerCase() === str.toLowerCase() || collisionOrg.long_name === str || (collisionOrg.aliases && collisionOrg.aliases.includes(str))) { return str } } diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 9bd0747bf..6f6ec4823 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -87,6 +87,21 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.message).to.equal(`The '${testRegistryOrg.short_name}' organization already exists.`) }) }) + it('Fails to create a new registry organization with an existing short name (case-insensitive)', async () => { + const bodyWithDuplicateCase = { ...createdOrg } + delete bodyWithDuplicateCase.UUID + delete bodyWithDuplicateCase.uuid + bodyWithDuplicateCase.short_name = bodyWithDuplicateCase.short_name.toUpperCase() + + const res = await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send(bodyWithDuplicateCase) + + expect(res).to.have.status(400) + expect(res.body.message).to.equal(`The '${bodyWithDuplicateCase.short_name}' organization already exists.`) + }) + it('Fails to create a new registry organization with invalid data', async () => { await chai.request(app) .post('/api/registry/org') From 1c133fc015229d3aad236d59d2bcd558d59f9662 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 5 May 2026 16:28:35 -0400 Subject: [PATCH 19/20] updated unit testS --- test/unit-tests/org/orgCreateADPTest.js | 1 + test/unit-tests/org/orgCreateTest.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/test/unit-tests/org/orgCreateADPTest.js b/test/unit-tests/org/orgCreateADPTest.js index 03849363a..e982cd20d 100644 --- a/test/unit-tests/org/orgCreateADPTest.js +++ b/test/unit-tests/org/orgCreateADPTest.js @@ -68,6 +68,7 @@ describe('Testing creating orgs with the ADP role', () => { // --- Method Stubbing -- sinon.stub(regOrgRepo, 'findOneByShortName').resolves(null) sinon.stub(regOrgRepo, 'isSecretariatByShortName').resolves(true) + sinon.stub(regOrgRepo, 'orgExists').resolves(false) // Stub aggregate to return an array with a fake object, so result[0] works const fakeAggregatedOrg = { UUID: 'org-uuid-123', short_name: 'fakeOrg', name: 'Fake Org Name' } diff --git a/test/unit-tests/org/orgCreateTest.js b/test/unit-tests/org/orgCreateTest.js index 946d9986b..c4662b082 100644 --- a/test/unit-tests/org/orgCreateTest.js +++ b/test/unit-tests/org/orgCreateTest.js @@ -168,6 +168,7 @@ describe('Testing the ORG_CREATE_SINGLE controller', () => { it('Should fail if the organization already exists', async () => { sinon.stub(orgRepo, 'findOneByShortName').resolves(orgFixtures.existentOrg) sinon.stub(baseOrgRepo, 'findOneByShortName').resolves(orgFixtures.existentOrg) + sinon.stub(baseOrgRepo, 'orgExists').resolves(true) const testOrgPayload = { ...orgFixtures.existentOrg } delete testOrgPayload.UUID @@ -197,6 +198,7 @@ describe('Testing the ORG_CREATE_SINGLE controller', () => { beforeEach(() => { sinon.stub(baseOrgRepo, 'findOneByShortName').resolves(null) + sinon.stub(baseOrgRepo, 'orgExists').resolves(false) aggregateOrgStub = sinon.stub(orgRepo, 'aggregate') aggregateRegOrgStub = sinon.stub(baseOrgRepo, 'aggregate') From d9dd1c7f5f631e405c375bad8f29954dd6254a1c Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 5 May 2026 16:28:45 -0400 Subject: [PATCH 20/20] Updated openapi docs --- api-docs/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 2440a87ab..6a5872063 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.2", "info": { - "version": "2.8.0", + "version": "2.7.5", "title": "CVE Services API", "description": "The CVE Services API supports automation tooling for the CVE Program. Credentials are required for most service endpoints. Representatives of CVE Numbering Authorities (CNAs) should use one of the methods below to obtain credentials:
  • If your organization already has an Organizational Administrator (OA) account for the CVE Services, ask your admin for credentials
  • Contact your Root (Google, INCIBE, JPCERT/CC, or Red Hat) or Top-Level Root (CISA ICS or MITRE) to request credentials

CVE data is to be in the JSON 5.2 CVE Record format. Details of the JSON 5.2 schema are located here.

Contact the CVE Services team", "contact": {