From b486aa8aab75756ab5441078efba93d29c355e62 Mon Sep 17 00:00:00 2001 From: James Dalphond Date: Wed, 29 Jul 2026 10:13:57 -0400 Subject: [PATCH 1/2] Add Secretariat endpoints for ROOT organization reports-to management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Secretariat-only endpoints for assigning and removing an organization’s reports-to relationship with a ROOT organization. - Add documented POST endpoints to add and remove ROOT `oversees` relationships. - Validate that the overseeing organization has ROOT authority and return clear errors when either organization is not found. - Reassign a reporting organization atomically by removing it from a previous ROOT organization before adding the new relationship. - Audit additions, removals, and reassignments for every affected ROOT organization. - Ignore `oversees` in standard organization PUT requests so reports-to relationships can only be managed through the dedicated endpoints. - Update existing CRUD coverage to verify PUT requests cannot create `oversees` or derived `reports_to` relationships. - Add ROOT organization integration coverage for successful assignment, reassignment, removal, invalid non-ROOT parents, missing organizations, and Secretariat-only authorization. --- api-docs/openapi.json | 212 ++++++++++++++++++ src/controller/registry.controller/index.js | 71 ++++++ .../registry.controller/org.error.js | 7 + .../org.registry.controller.js | 84 ++++++- src/repositories/baseOrgRepository.js | 57 +++++ .../registry-org/registryOrgCRUDTest.js | 28 +-- .../registry-org/rootOrgTest.js | 145 +++++++++++- 7 files changed, 578 insertions(+), 26 deletions(-) diff --git a/api-docs/openapi.json b/api-docs/openapi.json index efd583b28..f49f17b54 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -4267,6 +4267,218 @@ } } }, + "/registry/org/{shortname}/oversees/{shortname2}/add": { + "post": { + "tags": [ + "Registry Organization" + ], + "summary": "Assigns an organization to report to a ROOT organization (accessible to Secretariat only)", + "description": "

Access Control

User must belong to an organization with the Secretariat role.

Expected Behavior

The organization identified by shortname must have ROOT authority. The organization identified by shortname2 will report to it. If shortname2 already reported to a different ROOT. It will be removed from that and both will receive an audit entry.

", + "operationId": "registryOrgAddOverseeRelationship", + "parameters": [ + { + "name": "shortname", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The shortname of the overseeing ROOT organization" + }, + { + "name": "shortname2", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The shortname of the reporting organization" + }, + { + "$ref": "#/components/parameters/apiEntityHeader" + }, + { + "$ref": "#/components/parameters/apiUserHeader" + }, + { + "$ref": "#/components/parameters/apiSecretHeader" + } + ], + "responses": { + "200": { + "description": "Reports-to relationship added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/registry/org/{shortname}/oversees/{shortname2}/remove": { + "post": { + "tags": [ + "Registry Organization" + ], + "summary": "Removes an organization reports-to relationship (accessible to Secretariat only)", + "description": "

Access Control

User must belong to an organization with the Secretariat role.

Expected Behavior

The organization identified by shortname must have ROOT authority. The organization identified by shortname2 will no longer report to it.

", + "operationId": "registryOrgRemoveOverseeRelationship", + "parameters": [ + { + "name": "shortname", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The shortname of the overseeing ROOT organization" + }, + { + "name": "shortname2", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The shortname of the reporting organization" + }, + { + "$ref": "#/components/parameters/apiEntityHeader" + }, + { + "$ref": "#/components/parameters/apiUserHeader" + }, + { + "$ref": "#/components/parameters/apiSecretHeader" + } + ], + "responses": { + "200": { + "description": "Reports-to relationship removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, "/registry/org/{shortname}/user/{username}/grant-role": { "post": { "tags": [ diff --git a/src/controller/registry.controller/index.js b/src/controller/registry.controller/index.js index 36874272e..ec2707800 100644 --- a/src/controller/registry.controller/index.js +++ b/src/controller/registry.controller/index.js @@ -1090,6 +1090,77 @@ router.put('/registry/org/:shortname/user/:username/reset_secret', controller.USER_RESET_SECRET ) +router.post('/registry/org/:shortname/oversees/:shortname2/add', + /* + #swagger.tags = ['Registry Organization'] + #swagger.operationId = 'registryOrgAddOverseeRelationship' + #swagger.summary = 'Assigns an organization to report to a ROOT organization (accessible to Secretariat only)' + #swagger.description = ' +

Access Control

+

User must belong to an organization with the Secretariat role.

+

Expected Behavior

+

The organization identified by shortname must have ROOT authority. The organization identified by shortname2 will + report to it. If shortname2 already reported to a different ROOT. It will be removed from that and both will receive an audit entry.

' + #swagger.parameters['shortname'] = { description: 'The shortname of the overseeing ROOT organization' } + #swagger.parameters['shortname2'] = { description: 'The shortname of the reporting organization' } + #swagger.parameters['$ref'] = [ + '#/components/parameters/apiEntityHeader', + '#/components/parameters/apiUserHeader', + '#/components/parameters/apiSecretHeader' + ] + #swagger.responses[200] = { + description: 'Reports-to relationship added successfully', + content: { 'application/json': { schema: { type: 'object', properties: { message: { type: 'string' } } } } } + } + #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, + parseError, + parsePostParams, + registryOrgController.ADD_OVERSEE_RELATIONSHIP +) + +router.post('/registry/org/:shortname/oversees/:shortname2/remove', + /* + #swagger.tags = ['Registry Organization'] + #swagger.operationId = 'registryOrgRemoveOverseeRelationship' + #swagger.summary = 'Removes an organization reports-to relationship (accessible to Secretariat only)' + #swagger.description = ' +

Access Control

+

User must belong to an organization with the Secretariat role.

+

Expected Behavior

+

The organization identified by shortname must have ROOT authority. The organization identified by shortname2 will no longer report to it.

' + #swagger.parameters['shortname'] = { description: 'The shortname of the overseeing ROOT organization' } + #swagger.parameters['shortname2'] = { description: 'The shortname of the reporting organization' } + #swagger.parameters['$ref'] = [ + '#/components/parameters/apiEntityHeader', + '#/components/parameters/apiUserHeader', + '#/components/parameters/apiSecretHeader' + ] + #swagger.responses[200] = { + description: 'Reports-to relationship removed successfully', + content: { 'application/json': { schema: { type: 'object', properties: { message: { type: 'string' } } } } } + } + #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, + parseError, + parsePostParams, + registryOrgController.REMOVE_OVERSEE_RELATIONSHIP +) + router.post('/registry/org/:shortname/user/:username/grant-role', /* #swagger.tags = ['Registry User'] diff --git a/src/controller/registry.controller/org.error.js b/src/controller/registry.controller/org.error.js index c47c26a9f..c48ec8a77 100644 --- a/src/controller/registry.controller/org.error.js +++ b/src/controller/registry.controller/org.error.js @@ -141,6 +141,13 @@ class RegistryOrgControllerError extends idrErr.IDRError { err.message = `The following fields can only be modified by the Secretariat: ${fields.join(', ')}.` return err } + + overseeingOrgMustBeRoot (shortname) { + const err = {} + err.error = 'OVERSEEING_ORG_MUST_BE_ROOT' + err.message = `The '${shortname}' organization must have ROOT authority to oversee another organization.` + return err + } } module.exports = { diff --git a/src/controller/registry.controller/org.registry.controller.js b/src/controller/registry.controller/org.registry.controller.js index deb83fc3c..f4705e71e 100644 --- a/src/controller/registry.controller/org.registry.controller.js +++ b/src/controller/registry.controller/org.registry.controller.js @@ -420,6 +420,9 @@ async function updateOrg (req, res, next) { const userRepo = req.ctx.repositories.getBaseUserRepository() const conversationRepo = req.ctx.repositories.getConversationRepository() const { conversation, ...body } = req.ctx.body + // oversees is managed exclusively by the dedicated reports-to endpoints. + // Ignore it during standard organization updates. + delete body.oversees let updatedOrg let jointApprovalRequired @@ -549,7 +552,15 @@ async function updateOrg (req, res, next) { // Update Org full will cause a write to the Conversations collection, to avoid a read-after-write issue, we need to get the previous conversation data first const previousConversation = await conversationRepo.getAllByTargetUUID(await repo.getOrgUUID(shortName, { session }), isSecretariat, { session }) || [] - updatedOrg = await repo.updateOrgFull(shortName, req.ctx.body, { session }, false, requestingUser.UUID, isAdmin, isSecretariat) + updatedOrg = await repo.updateOrgFull( + shortName, + conversation ? { ...body, conversation } : body, + { session }, + false, + requestingUser.UUID, + isAdmin, + isSecretariat + ) jointApprovalRequired = _.get(updatedOrg, 'joint_approval_required', false) _.unset(updatedOrg, 'joint_approval_required') // append previous conversations to any conversations that are in the org already @@ -659,6 +670,75 @@ async function deleteOrg (req, res, next) { } } +/** + * Adds or removes a reports-to relationship for a ROOT organization. + * + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {function} next - The Express next function. + * @param {'add'|'remove'} action - The relationship operation to perform. + * @returns {Promise} A promise that resolves when the response is sent. + */ +async function updateOverseeRelationship (req, res, next, action) { + try { + const session = await mongoose.startSession({ causalConsistency: false }) + const overseeingOrgShortName = req.ctx.params.shortname + const reportingOrgShortName = req.params.shortname2 + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const userRepo = req.ctx.repositories.getBaseUserRepository() + + try { + session.startTransaction({ readPreference: 'primary' }) + const overseeingOrg = await orgRepo.findOneByShortName(overseeingOrgShortName, { session }) + if (!overseeingOrg) { + await session.abortTransaction() + return res.status(404).json(error.orgDnePathParam(overseeingOrgShortName)) + } + + if (!overseeingOrg.authority?.includes('ROOT')) { + await session.abortTransaction() + return res.status(400).json(error.overseeingOrgMustBeRoot(overseeingOrgShortName)) + } + + const reportingOrg = await orgRepo.findOneByShortName(reportingOrgShortName, { session }) + if (!reportingOrg) { + await session.abortTransaction() + return res.status(404).json(error.orgDnePathParam(reportingOrgShortName)) + } + + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) + if (action === 'add') { + await orgRepo.addOverseeRelationship(overseeingOrgShortName, reportingOrg.UUID, { session }, requestingUserUUID) + } else { + await orgRepo.removeOverseeRelationship(overseeingOrgShortName, reportingOrg.UUID, { session }, requestingUserUUID) + } + + await session.commitTransaction() + } catch (updateErr) { + await session.abortTransaction() + throw updateErr + } finally { + await session.endSession() + } + + const message = action === 'add' + ? `${reportingOrgShortName} organization now reports to ${overseeingOrgShortName}.` + : `${reportingOrgShortName} organization no longer reports to ${overseeingOrgShortName}.` + logger.info({ uuid: req.ctx.uuid, message }) + return res.status(200).json({ message }) + } catch (err) { + next(err) + } +} + +async function addOverseeRelationship (req, res, next) { + return updateOverseeRelationship(req, res, next, 'add') +} + +async function removeOverseeRelationship (req, res, next) { + return updateOverseeRelationship(req, res, next, 'remove') +} + /** * Retrieves all users for the organization with the specified short name. * @@ -928,6 +1008,8 @@ module.exports = { CREATE_ORG: createOrg, UPDATE_ORG: updateOrg, DELETE_ORG: deleteOrg, + ADD_OVERSEE_RELATIONSHIP: addOverseeRelationship, + REMOVE_OVERSEE_RELATIONSHIP: removeOverseeRelationship, USER_ALL: getUsers, USER_CREATE_SINGLE: createUserByOrg, EDIT_CONVERSATION: editConversationForOrg diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 5591e8e96..515c72280 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -462,6 +462,63 @@ class BaseOrgRepository extends BaseRepository { return updatedOrg } + /** + * @async + * @function addOverseeRelationship + * @description Assigns a reporting organization to a ROOT organization. A reporting organization can have only one overseeing organization. + * @param {string} overseeingOrgShortName - The short name of the ROOT organization. + * @param {string} reportingOrgUUID - The UUID of the organization that reports to the ROOT organization. + * @param {object} [options={}] - Optional settings for the repository query. + * @param {string|null} [requestingUserUUID=null] - The requester UUID used for audit documentation. + * @returns {Promise} The updated ROOT organization. + */ + async addOverseeRelationship (overseeingOrgShortName, reportingOrgUUID, options = {}, requestingUserUUID = null) { + const overseeingOrg = await RootOrgModel.findOne({ short_name: overseeingOrgShortName }, null, options) + const originalOverseeingOrg = overseeingOrg.toObject() + const previousOverseeingOrgs = await RootOrgModel.find({ oversees: reportingOrgUUID }, null, options) + + for (const previousOverseeingOrg of previousOverseeingOrgs) { + if (previousOverseeingOrg.UUID === overseeingOrg.UUID) continue + const originalPreviousOverseeingOrg = previousOverseeingOrg.toObject() + previousOverseeingOrg.oversees = previousOverseeingOrg.oversees.filter(uuid => uuid !== reportingOrgUUID) + if (requestingUserUUID) { + await createAuditLogEntry(previousOverseeingOrg, originalPreviousOverseeingOrg, requestingUserUUID, options) + } + await previousOverseeingOrg.save(options) + } + + overseeingOrg.oversees = [...new Set([...(overseeingOrg.oversees || []), reportingOrgUUID])] + if (requestingUserUUID) { + await createAuditLogEntry(overseeingOrg, originalOverseeingOrg, requestingUserUUID, options) + } + await overseeingOrg.save(options) + + return overseeingOrg + } + + /** + * @async + * @function removeOverseeRelationship + * @description Removes a reporting organization from a ROOT organization's oversee list. + * @param {string} overseeingOrgShortName - The short name of the ROOT organization. + * @param {string} reportingOrgUUID - The UUID of the organization that no longer reports to the ROOT organization. + * @param {object} [options={}] - Optional settings for the repository query. + * @param {string|null} [requestingUserUUID=null] - The requester UUID used for audit documentation. + * @returns {Promise} The updated ROOT organization. + */ + async removeOverseeRelationship (overseeingOrgShortName, reportingOrgUUID, options = {}, requestingUserUUID = null) { + const overseeingOrg = await RootOrgModel.findOne({ short_name: overseeingOrgShortName }, null, options) + const originalOverseeingOrg = overseeingOrg.toObject() + overseeingOrg.oversees = (overseeingOrg.oversees || []).filter(uuid => uuid !== reportingOrgUUID) + + if (requestingUserUUID) { + await createAuditLogEntry(overseeingOrg, originalOverseeingOrg, requestingUserUUID, options) + } + await overseeingOrg.save(options) + + return overseeingOrg + } + /** * @async * @function getAllOrgs diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 46fb6286c..8b0cf5320 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -871,7 +871,7 @@ describe('Testing /registry/org endpoints', () => { .delete('/api/registry/org/temp_org_updated_name') .set(secretariatHeaders) }) - it('Updates a registry organization to oversee another, and verifies the sub-org dynamically returns reports_to', async () => { + it('Does not update oversees through the standard registry organization PUT endpoint', async () => { // Create a sub org const subOrg = { short_name: 'sub_org_test', @@ -889,7 +889,7 @@ describe('Testing /registry/org endpoints', () => { createdSubOrgUUID = res.body.created.UUID }) - // Update the main org to oversee it + // A standard PUT must ignore oversees; reports-to relationships use dedicated endpoints. await chai.request(app) .put(`/api/registry/org/${createdOrg.short_name}`) .set(secretariatHeaders) @@ -899,36 +899,20 @@ describe('Testing /registry/org endpoints', () => { }) .then(res => { expect(res).to.have.status(200) - expect(res.body.updated.oversees).to.be.an('array').that.includes(createdSubOrgUUID) + expect(res.body.updated.oversees || []).to.not.include(createdSubOrgUUID) }) const BaseOrg = require('../../../src/model/baseorg') const registryOrgCheck = await BaseOrg.findOne({ short_name: createdOrg.short_name }) - expect(registryOrgCheck.oversees).to.be.an('array').that.includes(createdSubOrgUUID) + expect(registryOrgCheck.oversees || []).to.not.include(createdSubOrgUUID) - // Assert that the sub org dynamically returns reports_to matching the main org's UUID + // The reporting org must not acquire a computed reports_to relationship. await chai.request(app) .get(`/api/registry/org/${subOrg.short_name}`) .set(secretariatHeaders) .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) + expect(res.body).to.not.have.property('reports_to') }) // Cleanup sub org diff --git a/test/integration-tests/registry-org/rootOrgTest.js b/test/integration-tests/registry-org/rootOrgTest.js index 689a0af81..18c4bd7e1 100644 --- a/test/integration-tests/registry-org/rootOrgTest.js +++ b/test/integration-tests/registry-org/rootOrgTest.js @@ -16,6 +16,8 @@ const testRootOrg = { authority: ['ROOT'] } let createdOrg +let reportingOrg +let secondRootOrg describe('Testing ROOT Organization Type', () => { context('Creating a ROOT org', () => { @@ -55,6 +57,134 @@ describe('Testing ROOT Organization Type', () => { }) }) + context('Managing reports-to relationships', () => { + before(async () => { + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send({ + short_name: 'reporting_org_for_root', + long_name: 'Reporting Organization', + authority: ['CNA'], + id_quota: 100 + }) + .then((res) => { + expect(res).to.have.status(200) + reportingOrg = res.body.created + }) + + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send({ + short_name: 'second_root_org_test', + long_name: 'Second Root Organization', + authority: ['ROOT'] + }) + .then((res) => { + expect(res).to.have.status(200) + secondRootOrg = res.body.created + }) + }) + + it('Secretariat can assign an organization to report to a ROOT organization', async () => { + await chai.request(app) + .post(`/api/registry/org/${testRootOrg.short_name}/oversees/${reportingOrg.short_name}/add`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.message).to.equal(`${reportingOrg.short_name} organization now reports to ${testRootOrg.short_name}.`) + }) + + await chai.request(app) + .get(`/api/registry/org/${reportingOrg.short_name}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.reports_to).to.equal(createdOrg.UUID) + }) + + await chai.request(app) + .get(`/api/registry/org/${testRootOrg.short_name}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.oversees).to.include(reportingOrg.UUID) + }) + }) + + it('Assigning a new ROOT parent removes the previous reports-to relationship', async () => { + await chai.request(app) + .post(`/api/registry/org/${secondRootOrg.short_name}/oversees/${reportingOrg.short_name}/add`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + }) + + await chai.request(app) + .get(`/api/registry/org/${testRootOrg.short_name}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.oversees || []).to.not.include(reportingOrg.UUID) + }) + + await chai.request(app) + .get(`/api/registry/org/${reportingOrg.short_name}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.reports_to).to.equal(secondRootOrg.UUID) + }) + }) + + it('Secretariat can remove a reports-to relationship', async () => { + await chai.request(app) + .post(`/api/registry/org/${secondRootOrg.short_name}/oversees/${reportingOrg.short_name}/remove`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.message).to.equal(`${reportingOrg.short_name} organization no longer reports to ${secondRootOrg.short_name}.`) + }) + + await chai.request(app) + .get(`/api/registry/org/${reportingOrg.short_name}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.not.have.property('reports_to') + }) + }) + + it('Rejects a non-ROOT overseeing organization', async () => { + await chai.request(app) + .post(`/api/registry/org/${reportingOrg.short_name}/oversees/${testRootOrg.short_name}/add`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(400) + expect(res.body.error).to.equal('OVERSEEING_ORG_MUST_BE_ROOT') + }) + }) + + it('Returns not found when either organization does not exist', async () => { + await chai.request(app) + .post(`/api/registry/org/missing_root/oversees/${reportingOrg.short_name}/add`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(404) + expect(res.body.error).to.equal('ORG_DNE_PARAM') + }) + + await chai.request(app) + .post(`/api/registry/org/${testRootOrg.short_name}/oversees/missing_reporting_org/add`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(404) + expect(res.body.error).to.equal('ORG_DNE_PARAM') + }) + }) + }) + context('ROOT admin permissions', () => { before(async () => { // Create a Root Admin user @@ -108,17 +238,26 @@ describe('Testing ROOT Organization Type', () => { }) }) - it('ROOT admin cannot edit oversees', async () => { + it('ROOT admin cannot set oversees through the standard PUT endpoint', async () => { await chai.request(app) .put(`/api/registry/org/${testRootOrg.short_name}`) .set(rootAdminHeaders) .send({ ...createdOrg, - oversees: ['some_other_uuid'] + oversees: [reportingOrg.UUID] + }) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.updated.oversees || []).to.not.include(reportingOrg.UUID) }) + }) + + it('ROOT admin cannot manage reports-to relationships', async () => { + await chai.request(app) + .post(`/api/registry/org/${testRootOrg.short_name}/oversees/${reportingOrg.short_name}/add`) + .set(rootAdminHeaders) .then((res) => { expect(res).to.have.status(403) - expect(res.body.error).to.equal('SECRETARIAT_ONLY') }) }) From 0b3dccb494cf8c3469280d5cfabd88045a4724b9 Mon Sep 17 00:00:00 2001 From: James Dalphond Date: Wed, 29 Jul 2026 10:45:49 -0400 Subject: [PATCH 2/2] Fixing lint issues --- api-docs/openapi.json | 2 +- src/controller/registry.controller/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api-docs/openapi.json b/api-docs/openapi.json index f49f17b54..71f81b07d 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -4273,7 +4273,7 @@ "Registry Organization" ], "summary": "Assigns an organization to report to a ROOT organization (accessible to Secretariat only)", - "description": "

Access Control

User must belong to an organization with the Secretariat role.

Expected Behavior

The organization identified by shortname must have ROOT authority. The organization identified by shortname2 will report to it. If shortname2 already reported to a different ROOT. It will be removed from that and both will receive an audit entry.

", + "description": "

Access Control

User must belong to an organization with the Secretariat role.

Expected Behavior

The organization identified by shortname must have ROOT authority. The organization identified by shortname2 will report to it. If shortname2 already reported to a different ROOT. It will be removed from that and both will receive an audit entry.

", "operationId": "registryOrgAddOverseeRelationship", "parameters": [ { diff --git a/src/controller/registry.controller/index.js b/src/controller/registry.controller/index.js index ec2707800..2735f8cdf 100644 --- a/src/controller/registry.controller/index.js +++ b/src/controller/registry.controller/index.js @@ -1099,7 +1099,7 @@ router.post('/registry/org/:shortname/oversees/:shortname2/add',

Access Control

User must belong to an organization with the Secretariat role.

Expected Behavior

-

The organization identified by shortname must have ROOT authority. The organization identified by shortname2 will +

The organization identified by shortname must have ROOT authority. The organization identified by shortname2 will report to it. If shortname2 already reported to a different ROOT. It will be removed from that and both will receive an audit entry.

' #swagger.parameters['shortname'] = { description: 'The shortname of the overseeing ROOT organization' } #swagger.parameters['shortname2'] = { description: 'The shortname of the reporting organization' }