From f6da1c0156947d61c3f7bf103df5123d0bf7c489 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 3 Jun 2026 14:21:45 -0400 Subject: [PATCH 1/2] Add expanded user map to registry org response --- api-docs/openapi.json | 14 +- .../get-registry-org-response.json | 43 +++++ src/controller/org.controller/index.js | 13 +- .../org.controller/org.middleware.js | 2 +- .../registry-org.controller.js | 62 +++++++ src/repositories/baseOrgRepository.js | 12 ++ src/repositories/baseUserRepository.js | 12 ++ .../registry-org/registryOrgCRUDTest.js | 76 ++++++++ .../org/registryOrgGetSingleTest.js | 169 ++++++++++++++++++ 9 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 test/unit-tests/org/registryOrgGetSingleTest.js diff --git a/api-docs/openapi.json b/api-docs/openapi.json index beb39f120..80d39b14e 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -2318,6 +2318,18 @@ }, "description": "The shortname or UUID of the registry organization" }, + { + "name": "expand", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "users" + ] + }, + "description": "Optional expanded related data. Accepted value: users." + }, { "$ref": "#/components/parameters/apiEntityHeader" }, @@ -7744,4 +7756,4 @@ } } } -} \ No newline at end of file +} diff --git a/schemas/registry-org/get-registry-org-response.json b/schemas/registry-org/get-registry-org-response.json index c8e967e1d..f52b464f3 100644 --- a/schemas/registry-org/get-registry-org-response.json +++ b/schemas/registry-org/get-registry-org-response.json @@ -224,6 +224,49 @@ } }, "description": "List of conversation messages associated with the organization" + }, + "_userMap": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "User's identifier or username" + }, + "name": { + "type": "object", + "properties": { + "first": { + "type": "string", + "description": "User's first name" + }, + "last": { + "type": "string", + "description": "User's last name" + }, + "middle": { + "type": "string", + "description": "User's middle name" + }, + "suffix": { + "type": "string", + "description": "User's name suffix" + } + } + }, + "org": { + "type": "object", + "properties": { + "short_name": { + "type": "string", + "description": "Short name of the organization associated with the user" + } + } + } + } + }, + "description": "Map of expanded user UUIDs to display metadata, included when expand=users is requested" } } } diff --git a/src/controller/org.controller/index.js b/src/controller/org.controller/index.js index 113c936fc..1061c4eba 100644 --- a/src/controller/org.controller/index.js +++ b/src/controller/org.controller/index.js @@ -280,6 +280,15 @@ router.get('/registry/org/:identifier',

Regular, CNA & Admin Users: Retrieves registry organization record for the specified shortname or UUID if it is the user's organization

Secretariat: Retrieves information about any registry organization

" #swagger.parameters['identifier'] = { description: 'The shortname or UUID of the registry organization' } + #swagger.parameters['expand'] = { + in: 'query', + description: 'Optional expanded related data. Accepted value: users.', + required: false, + schema: { + type: 'string', + enum: ['users'] + } + } #swagger.parameters['$ref'] = [ '#/components/parameters/apiEntityHeader', '#/components/parameters/apiUserHeader', @@ -338,7 +347,9 @@ router.get('/registry/org/:identifier', */ mw.useRegistry(), mw.validateUser, - query().custom((query) => { return mw.validateQueryParameterNames(query, ['']) }), + query().custom((query) => { return mw.validateQueryParameterNames(query, ['expand']) }), + query(['expand']).custom((val) => { return mw.containsNoInvalidCharacters(val) }), + query(['expand']).optional().isIn(['users']), parseError, parseGetParams, registryOrgController.SINGLE_ORG diff --git a/src/controller/org.controller/org.middleware.js b/src/controller/org.controller/org.middleware.js index 017cb5304..b79da3f65 100644 --- a/src/controller/org.controller/org.middleware.js +++ b/src/controller/org.controller/org.middleware.js @@ -380,7 +380,7 @@ function parsePostParams (req, res, next) { function parseGetParams (req, res, next) { utils.reqCtxMapping(req, 'params', ['shortname', 'username', 'identifier']) - utils.reqCtxMapping(req, 'query', ['page']) + utils.reqCtxMapping(req, 'query', ['page', 'expand']) next() } diff --git a/src/controller/registry-org.controller/registry-org.controller.js b/src/controller/registry-org.controller/registry-org.controller.js index 027c20c8e..535ff792c 100644 --- a/src/controller/registry-org.controller/registry-org.controller.js +++ b/src/controller/registry-org.controller/registry-org.controller.js @@ -8,6 +8,63 @@ const conversationErrors = require('../conversation.controller/error') const convoError = new conversationErrors.ConversationControllerError() const validateUUID = require('uuid').validate +function addUUIDsToSet (uuidSet, values) { + if (!Array.isArray(values)) return + values.filter(Boolean).forEach(uuid => uuidSet.add(uuid)) +} + +function addConversationAuthorUUIDsToSet (uuidSet, conversations) { + if (!Array.isArray(conversations)) return + conversations.forEach(conversation => { + if (conversation.author_id) { + uuidSet.add(conversation.author_id) + } + }) +} + +function buildOrgShortNameByUserUUID (orgs, requestedUserUUIDs) { + const orgShortNameByUserUUID = {} + orgs.forEach(org => { + const orgObject = typeof org.toObject === 'function' ? org.toObject() : org + if (!Array.isArray(orgObject.users)) return + + orgObject.users.forEach(userUUID => { + if (requestedUserUUIDs.has(userUUID)) { + orgShortNameByUserUUID[userUUID] = orgObject.short_name + } + }) + }) + return orgShortNameByUserUUID +} + +async function buildUserMap (org, userRepo, orgRepo) { + const userUUIDs = new Set() + addUUIDsToSet(userUUIDs, org.users) + addUUIDsToSet(userUUIDs, org.admins) + addUUIDsToSet(userUUIDs, org.contact_info?.additional_contacts) + addConversationAuthorUUIDsToSet(userUUIDs, org.conversation) + + if (userUUIDs.size === 0) { + return {} + } + + const userUUIDList = Array.from(userUUIDs) + const users = await userRepo.findUsersByUUIDs(userUUIDList) + const orgs = await orgRepo.findOrgsByUserUUIDs(userUUIDList) + const orgShortNameByUserUUID = buildOrgShortNameByUserUUID(orgs, userUUIDs) + + return users.reduce((userMap, user) => { + const userObject = typeof user.toObject === 'function' ? user.toObject() : user + const orgShortName = orgShortNameByUserUUID[userObject.UUID] + userMap[userObject.UUID] = _.omitBy({ + username: userObject.username, + name: userObject.name, + org: orgShortName ? { short_name: orgShortName } : undefined + }, _.isUndefined) + return userMap + }, {}) +} + /** * Retrieves information about all registry organizations. * @@ -101,6 +158,11 @@ async function getOrg (req, res, next) { } else { returnValue.conversation = conversation?.length ? _.map(conversation, c => _.omit(c, ['__v', '_id', 'UUID', 'previous_conversation_uuid', 'next_conversation_uuid', 'target_uuid', 'visibility'])) : undefined } + + if (req.ctx.query?.expand === 'users') { + const userRepo = req.ctx.repositories.getBaseUserRepository() + returnValue._userMap = await buildUserMap(returnValue, userRepo, repo) + } } } catch (error) { // Handle the specific error thrown by BaseOrgRepository.createOrg diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index db131de10..f6c71591e 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -204,6 +204,18 @@ class BaseOrgRepository extends BaseRepository { return null } + async findOrgsByUserUUIDs (userUUIDs, options = {}) { + if (!Array.isArray(userUUIDs) || userUUIDs.length === 0) { + return [] + } + + return await BaseOrgModel.find( + { users: { $in: userUUIDs } }, + { _id: 0, UUID: 1, short_name: 1, users: 1 }, + options + ) + } + /** * @async * @function orgExists diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index e8e46029d..83a92ad91 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -177,6 +177,18 @@ class BaseUserRepository extends BaseRepository { return user || null } + async findUsersByUUIDs (uuids, options = {}) { + if (!Array.isArray(uuids) || uuids.length === 0) { + return [] + } + + return await BaseUser.find( + { UUID: { $in: uuids } }, + { _id: 0, UUID: 1, username: 1, name: 1 }, + options + ) + } + /** * @async * @function deleteUserByUUID diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index a3f2e54e7..c1271648f 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -2,6 +2,7 @@ const chai = require('chai') const expect = chai.expect chai.use(require('chai-http')) +const { v4: uuidv4 } = require('uuid') const constants = require('../constants.js') const app = require('../../../src/index.js') @@ -296,6 +297,81 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.advisory_locations).to.deep.equal(createdOrg.advisory_locations) }) }) + it('Gets a registry organization with expanded user metadata', async () => { + const uniqueSuffix = uuidv4().replace(/-/g, '').slice(0, 20) + const orgShortName = `expand_${uniqueSuffix}` + const username = `${orgShortName}@example.com` + let orgUUID + let createdUserUUID + + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send({ + short_name: orgShortName, + long_name: 'Expanded User Map Test Org', + authority: ['CNA'], + hard_quota: 1000 + }) + .then((res) => { + expect(res).to.have.status(200) + orgUUID = res.body.created.UUID + }) + + await chai.request(app) + .post(`/api/registry/org/${orgShortName}/user`) + .set(secretariatHeaders) + .send({ + username, + name: { + first: 'Expanded', + last: 'User' + } + }) + .then((res) => { + expect(res).to.have.status(200) + createdUserUUID = res.body.created.UUID + }) + + const conversationRes = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(secretariatHeaders) + .send({ + visibility: 'public', + body: 'This is a test conversation for expanded user metadata' + }) + + expect(conversationRes).to.have.status(200) + const secretariatUserUUID = conversationRes.body.author_id + + await chai.request(app) + .get(`/api/registry/org/${orgShortName}?expand=users`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('_userMap') + + expect(res.body._userMap).to.have.property(createdUserUUID) + expect(res.body._userMap[createdUserUUID]).to.deep.equal({ + username, + name: { + first: 'Expanded', + last: 'User' + }, + org: { + short_name: orgShortName + } + }) + + expect(res.body._userMap).to.have.property(secretariatUserUUID) + expect(res.body._userMap[secretariatUserUUID]).to.include({ + username: 'test_secretariat_0@mitre.org' + }) + expect(res.body._userMap[secretariatUserUUID].org).to.deep.equal({ + short_name: 'mitre' + }) + }) + }) it('Strips author_id from conversations for non-secretariats', async () => { // 1. Get win_5 org UUID let win5UUID diff --git a/test/unit-tests/org/registryOrgGetSingleTest.js b/test/unit-tests/org/registryOrgGetSingleTest.js new file mode 100644 index 000000000..02231af21 --- /dev/null +++ b/test/unit-tests/org/registryOrgGetSingleTest.js @@ -0,0 +1,169 @@ +/* eslint-disable no-unused-expressions */ +const sinon = require('sinon') +const chai = require('chai') +const expect = chai.expect + +const { SINGLE_ORG } = require('../../../src/controller/registry-org.controller/registry-org.controller') + +describe('Testing the GET /registry/org/:identifier endpoint in Registry Org Controller', () => { + let status, json, res, next, orgRepo, conversationRepo, userRepo, req + + const requesterOrg = { + UUID: 'requester-org-uuid', + short_name: 'mitre' + } + + const targetOrg = { + UUID: 'target-org-uuid', + short_name: 'activity_6', + long_name: 'Activity Six', + users: ['user-uuid', 'admin-uuid'], + admins: ['admin-uuid'], + contact_info: { + additional_contacts: ['contact-uuid'] + } + } + + const conversations = [ + { + UUID: 'conversation-uuid', + target_uuid: targetOrg.UUID, + author_id: 'secretariat-user-uuid', + author_name: 'Test Secretariat', + author_role: 'Secretariat', + visibility: 'public', + body: 'A public conversation message' + } + ] + + beforeEach(() => { + status = sinon.stub() + json = sinon.spy() + res = { status, json } + status.returns(res) + next = sinon.spy() + + orgRepo = { + findOneByShortName: sinon.stub().resolves(requesterOrg), + isSecretariat: sinon.stub().resolves(true), + getOrg: sinon.stub().resolves({ ...targetOrg, contact_info: { ...targetOrg.contact_info } }), + findOrgsByUserUUIDs: sinon.stub().resolves([ + { + short_name: targetOrg.short_name, + users: ['user-uuid', 'admin-uuid', 'contact-uuid'] + }, + { + short_name: 'mitre', + users: ['secretariat-user-uuid'] + } + ]) + } + + conversationRepo = { + getAllByTargetUUID: sinon.stub().resolves(conversations) + } + + userRepo = { + findUsersByUUIDs: sinon.stub().resolves([ + { + UUID: 'user-uuid', + username: 'user@activity_6.com', + name: { first: 'Regular', last: 'User' } + }, + { + UUID: 'admin-uuid', + username: 'admin@activity_6.com', + name: { first: 'Admin', last: 'User' } + }, + { + UUID: 'contact-uuid', + username: 'contact@activity_6.com', + name: { first: 'Contact', last: 'User' } + }, + { + UUID: 'secretariat-user-uuid', + username: 'secretariat@mitre.org', + name: { first: 'Secretariat', last: 'User' } + } + ]) + } + + req = { + ctx: { + org: requesterOrg.short_name, + uuid: 'request-uuid', + params: { + identifier: targetOrg.short_name + }, + query: {}, + repositories: { + getBaseOrgRepository: sinon.stub().returns(orgRepo), + getConversationRepository: sinon.stub().returns(conversationRepo), + getBaseUserRepository: sinon.stub().returns(userRepo) + } + } + } + }) + + afterEach(() => { + sinon.restore() + }) + + it('does not include _userMap when expand=users is not requested', async () => { + req.ctx.repositories.getBaseUserRepository = sinon.stub().throws(new Error('getBaseUserRepository should not be called')) + + await SINGLE_ORG(req, res, next) + + expect(next.called).to.be.false + expect(status.args[0][0]).to.equal(200) + expect(json.args[0][0]).to.not.have.property('_userMap') + expect(req.ctx.repositories.getBaseUserRepository.called).to.be.false + }) + + it('includes _userMap for org users, admins, contacts, and conversation authors when expand=users is requested', async () => { + req.ctx.query.expand = 'users' + + await SINGLE_ORG(req, res, next) + + expect(next.called).to.be.false + expect(status.args[0][0]).to.equal(200) + + const responseBody = json.args[0][0] + expect(responseBody).to.have.property('_userMap') + expect(responseBody._userMap).to.deep.equal({ + 'user-uuid': { + username: 'user@activity_6.com', + name: { first: 'Regular', last: 'User' }, + org: { short_name: 'activity_6' } + }, + 'admin-uuid': { + username: 'admin@activity_6.com', + name: { first: 'Admin', last: 'User' }, + org: { short_name: 'activity_6' } + }, + 'contact-uuid': { + username: 'contact@activity_6.com', + name: { first: 'Contact', last: 'User' }, + org: { short_name: 'activity_6' } + }, + 'secretariat-user-uuid': { + username: 'secretariat@mitre.org', + name: { first: 'Secretariat', last: 'User' }, + org: { short_name: 'mitre' } + } + }) + + expect(userRepo.findUsersByUUIDs.args[0][0]).to.have.members([ + 'user-uuid', + 'admin-uuid', + 'contact-uuid', + 'secretariat-user-uuid' + ]) + expect(orgRepo.findOrgsByUserUUIDs.args[0][0]).to.have.members([ + 'user-uuid', + 'admin-uuid', + 'contact-uuid', + 'secretariat-user-uuid' + ]) + }) +}) From d526016723bfd5c481bf0eb0161d7c24e2264e7e Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 3 Jun 2026 14:48:05 -0400 Subject: [PATCH 2/2] Fixing lint issues --- .../org/registryOrgGetSingleTest.js | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/unit-tests/org/registryOrgGetSingleTest.js b/test/unit-tests/org/registryOrgGetSingleTest.js index 02231af21..0b87e05fa 100644 --- a/test/unit-tests/org/registryOrgGetSingleTest.js +++ b/test/unit-tests/org/registryOrgGetSingleTest.js @@ -6,7 +6,7 @@ const expect = chai.expect const { SINGLE_ORG } = require('../../../src/controller/registry-org.controller/registry-org.controller') describe('Testing the GET /registry/org/:identifier endpoint in Registry Org Controller', () => { - let status, json, res, next, orgRepo, conversationRepo, userRepo, req + let status, json, res, next, orgRepo, conversationRepo, userRepo, req, conversations const requesterOrg = { UUID: 'requester-org-uuid', @@ -24,18 +24,6 @@ describe('Testing the GET /registry/org/:identifier endpoint in Registry Org Con } } - const conversations = [ - { - UUID: 'conversation-uuid', - target_uuid: targetOrg.UUID, - author_id: 'secretariat-user-uuid', - author_name: 'Test Secretariat', - author_role: 'Secretariat', - visibility: 'public', - body: 'A public conversation message' - } - ] - beforeEach(() => { status = sinon.stub() json = sinon.spy() @@ -43,6 +31,18 @@ describe('Testing the GET /registry/org/:identifier endpoint in Registry Org Con status.returns(res) next = sinon.spy() + conversations = [ + { + UUID: 'conversation-uuid', + target_uuid: targetOrg.UUID, + author_id: 'secretariat-user-uuid', + author_name: 'Test Secretariat', + author_role: 'Secretariat', + visibility: 'public', + body: 'A public conversation message' + } + ] + orgRepo = { findOneByShortName: sinon.stub().resolves(requesterOrg), isSecretariat: sinon.stub().resolves(true),