diff --git a/src/controller/conversation.controller/conversation.controller.js b/src/controller/conversation.controller/conversation.controller.js index 73d5335bf..1b4dbb3ad 100644 --- a/src/controller/conversation.controller/conversation.controller.js +++ b/src/controller/conversation.controller/conversation.controller.js @@ -2,6 +2,8 @@ const mongoose = require('mongoose') const logger = require('../../middleware/logger') const getConstants = require('../../../src/constants').getConstants const CONSTANTS = getConstants() +const errors = require('./error') +const error = new errors.ConversationControllerError() async function getAllConversations (req, res, next) { const repo = req.ctx.repositories.getConversationRepository() @@ -42,8 +44,8 @@ async function createConversationForTargetUUID (req, res, next) { const user = await userRepo.findOneByUsernameAndOrgShortname(requesterUsername, requesterOrg, { session }) - if (!body.body) { - return res.status(400).json({ message: 'Missing required field body' }) + if (typeof body !== 'object' || !body.body || !repo.validateConversation(body)) { + return res.status(400).json(error.invalidConversationObject()) } const result = await repo.createConversation(targetUUID, body, user, true, { session }) @@ -73,8 +75,56 @@ async function createConversationForTargetUUID (req, res, next) { } } +async function updateConversationByUUID (req, res, next) { + const session = await mongoose.startSession() + + try { + session.startTransaction() + + const repo = req.ctx.repositories.getConversationRepository() + const conversationUUID = req.params.uuid + const body = req.body + + // Check if conversation exists + const conversation = await repo.findOneByUUID(conversationUUID, { session }) + if (!conversation) { + logger.info({ uuid: req.ctx.uuid, message: `No conversation found with UUID ${conversationUUID}` }) + return res.status(404).json(error.conversationDne(conversationUUID)) + } + + // Validate body + if (typeof body !== 'object' || !(body.body || body.visibility) || !repo.validateConversation(body)) { + logger.info({ uuid: req.ctx.uuid, message: 'The conversation could not be edited because the request body was invalid.' }) + return res.status(400).json(error.invalidConversationEditObject()) + } + + const result = await repo.editConversation(conversationUUID, body, { session }) + await session.commitTransaction() + return res.status(200).json(result) + } catch (err) { + if (session && session.inTransaction()) { + await session.abortTransaction() + } + next(err) + } finally { + if (session && session.id) { + // Check if session is still valid before trying to end + try { + await session.endSession() + } catch (sessionEndError) { + logger.error({ + uuid: req.ctx.uuid, + message: 'Error ending session in finally block', + error: sessionEndError + }) + } + } + } +} + module.exports = { getAllConversations, getConversationsForTargetUUID, - createConversationForTargetUUID + createConversationForTargetUUID, + updateConversationByUUID } diff --git a/src/controller/conversation.controller/error.js b/src/controller/conversation.controller/error.js new file mode 100644 index 000000000..8d116c691 --- /dev/null +++ b/src/controller/conversation.controller/error.js @@ -0,0 +1,49 @@ +const idrErr = require('../../utils/error') + +class ConversationControllerError extends idrErr.IDRError { + conversationDne (uuid) { + const err = {} + err.error = 'CONVERSATION_DNE' + err.message = `The conversation with UUID ${uuid} does not exist.` + return err + } + + conversationIndexDne (shortname, index) { + const err = {} + err.error = 'CONVERSATION_INDEX_DNE' + err.message = `No conversation exists at index ${index} for the ${shortname} organization.` + return err + } + + notAllowedToEditConversation () { + const err = {} + err.error = 'NOT_ALLOWED_TO_EDIT_CONVERSATION' + err.message = 'You must be the original author or Secretariat to edit this conversation.' + return err + } + + notAllowedToChangeConversationVisibility () { + const err = {} + err.error = 'NOT_ALLOWED_TO_CHANGE_CONVERSATION_VISIBILITY' + err.message = 'Only the Secretariat is allowed to change the visibility of a conversation.' + return err + } + + invalidConversationObject () { + const err = {} + err.error = 'BAD_INPUT' + err.message = "Parameters were invalid: conversation object must include property 'body' (string) and optionally 'visibility' ('public' or 'private')." + return err + } + + invalidConversationEditObject () { + const err = {} + err.error = 'BAD_INPUT' + err.message = "Parameters were invalid: conversation object must include at least one of the following properties: 'body' (string) or 'visibility' ('public' or 'private')." + return err + } +} + +module.exports = { + ConversationControllerError +} diff --git a/src/controller/conversation.controller/index.js b/src/controller/conversation.controller/index.js index 421cc8e5e..45eeeee53 100644 --- a/src/controller/conversation.controller/index.js +++ b/src/controller/conversation.controller/index.js @@ -257,4 +257,99 @@ router.post('/conversation/target/:uuid', controller.createConversationForTargetUUID ) +// Update conversation - SEC only +router.put('/conversation/:uuid', + /* + #swagger.tags = ['Conversation'] + #swagger.operationId = 'updateConversationByUUID' + #swagger.summary = "Updates a conversation by UUID (accessible to Secretariat only)" + #swagger.description = " +
User must belong to an organization with the Secretariat role
+Secretariat: Updates the conversation with the specified UUID
" + #swagger.parameters['uuid'] = { description: 'The UUID of the conversation to update' } + #swagger.parameters['$ref'] = [ + '#/components/parameters/apiEntityHeader', + '#/components/parameters/apiUserHeader', + '#/components/parameters/apiSecretHeader' + ] + #swagger.requestBody = { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + body: { + type: 'string', + description: 'The updated content of the conversation message' + }, + visibility: { + type: 'string', + enum: ['private', 'public'], + description: 'The updated visibility of the conversation message' + } + } + } + } + } + } + #swagger.responses[200] = { + description: 'Returns the updated conversation', + content: { + "application/json": { + schema: { + $ref: '../schemas/conversation/conversation.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[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.validateUser, + mw.onlySecretariat, + param(['uuid']).isUUID(4), + controller.updateConversationByUUID +) + module.exports = router diff --git a/src/controller/registry-org.controller/error.js b/src/controller/registry-org.controller/error.js index d4af5f1e6..dd5ffe352 100644 --- a/src/controller/registry-org.controller/error.js +++ b/src/controller/registry-org.controller/error.js @@ -91,34 +91,6 @@ class RegistryOrgControllerError extends idrErr.IDRError { err.message = 'The requested user can not be created and added to the organization because the organization has hit its limit of 100 users. Contact the Secretariat.' return err } - - conversationDne (shortname, index) { - const err = {} - err.error = 'CONVERSATION_DNE' - err.message = `The conversation at index ${index} does not exist for the ${shortname} organization.` - return err - } - - notAllowedToEditConversation () { - const err = {} - err.error = 'NOT_ALLOWED_TO_EDIT_CONVERSATION' - err.message = 'You must be the original author or Secretariat to edit this conversation.' - return err - } - - notAllowedToChangeConversationVisibility () { - const err = {} - err.error = 'NOT_ALLOWED_TO_CHANGE_CONVERSATION_VISIBILITY' - err.message = 'Only the Secretariat is allowed to change the visibility of a conversation.' - return err - } - - invalidConversationObject () { - const err = {} - err.error = 'BAD_INPUT' - err.message = 'Parameters were invalid: conversation must be an object with a body.' - return err - } } module.exports = { diff --git a/src/controller/registry-org.controller/registry-org.controller.js b/src/controller/registry-org.controller/registry-org.controller.js index 64da4d038..8d4292079 100644 --- a/src/controller/registry-org.controller/registry-org.controller.js +++ b/src/controller/registry-org.controller/registry-org.controller.js @@ -4,6 +4,8 @@ const { getConstants } = require('../../constants') const _ = require('lodash') const errors = require('./error') const error = new errors.RegistryOrgControllerError() +const conversationErrors = require('../conversation.controller/error') +const convoError = new conversationErrors.ConversationControllerError() const validateUUID = require('uuid').validate /** @@ -241,10 +243,6 @@ async function updateOrg (req, res, next) { let updatedOrg let jointApprovalRequired - if (conversation && (typeof conversation !== 'object' || !conversation.body)) { - return res.status(400).json(error.invalidConversationObject()) - } - try { session.startTransaction() const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org, { session }) @@ -280,6 +278,7 @@ async function updateOrg (req, res, next) { } } + // Validate org const result = repo.validateOrg(body, { session }) if (!result.isValid) { logger.error(JSON.stringify({ uuid: req.ctx.uuid, message: 'CVE JSON schema validation FAILED.' })) @@ -287,6 +286,19 @@ async function updateOrg (req, res, next) { return res.status(400).json({ message: 'Parameters were invalid', errors: result.errors }) } + // Validate conversation (if it exists) + if (conversation) { + if ( + typeof conversation !== 'object' || + !conversation.body || + !conversationRepo.validateConversation(conversation) + ) { + logger.error(JSON.stringify({ uuid: req.ctx.uuid, message: 'Invalid conversation object.' })) + await session.abortTransaction() + return res.status(400).json(convoError.invalidConversationObject()) + } + } + // Check for duplicate short_name if (body?.short_name !== shortName && await repo.orgExists(body?.short_name, { session })) { logger.info({ @@ -634,7 +646,17 @@ async function editConversationForOrg (req, res, next) { const conversation = await conversationRepo.findByTargetUUIDAndIndex(orgUUID, index, { session }) if (!conversation) { logger.info({ uuid: req.ctx.uuid, message: `The conversation at index ${index} does not exist for the ${orgShortName} organization.` }) - return res.status(404).json(error.conversationDne(orgShortName, index)) + return res.status(404).json(convoError.conversationIndexDne(orgShortName, index)) + } + + // Validate body + if ( + typeof incomingParameters !== 'object' || + !(incomingParameters.body || incomingParameters.visibility) || + !conversationRepo.validateConversation(incomingParameters) + ) { + logger.info({ uuid: req.ctx.uuid, message: 'The conversation could not be edited because the request body was invalid.' }) + return res.status(400).json(convoError.invalidConversationObject()) } // Check if user has permissions to edit conversation @@ -642,13 +664,13 @@ async function editConversationForOrg (req, res, next) { const userUUID = await userRepo.getUserUUID(requesterUsername, req.ctx.org, { session }) if (conversation.author_id !== userUUID && !isSecretariat) { logger.info({ uuid: req.ctx.uuid, message: 'The user does not have permission to edit this conversation.' }) - return res.status(403).json(error.notAllowedToEditConversation()) + return res.status(403).json(convoError.notAllowedToEditConversation()) } // Check if user has permission to change visibility of conversation if (incomingParameters.visibility && !isSecretariat) { logger.info({ uuid: req.ctx.uuid, message: 'Only the Secretariat is allowed to change the visibility of a conversation.' }) - return res.status(403).json(error.notAllowedToChangeConversationVisibility()) + return res.status(403).json(convoError.notAllowedToChangeConversationVisibility()) } // Make the edit diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index 02a61d807..c8a8d48cc 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -62,6 +62,13 @@ class ConversationRepository extends BaseRepository { return conversation[0] } + validateConversation (conversation) { + if ((conversation.body && typeof conversation.body !== 'string') || (conversation.visibility && !['public', 'private'].includes(conversation.visibility))) { + return false + } + return true + } + async createConversation (targetUUID, body, user, isSecretariat, options = {}) { const { getUserFullName } = require('../utils/utils') const newUUID = uuid.v4() diff --git a/test/integration-tests/conversation/conversationTest.js b/test/integration-tests/conversation/conversationTest.js index 802199ff2..d2c5db976 100644 --- a/test/integration-tests/conversation/conversationTest.js +++ b/test/integration-tests/conversation/conversationTest.js @@ -145,6 +145,28 @@ describe('Testing Conversation endpoints', () => { }) }) }) + it('Should update a conversation by UUID as Secretariat', async () => { + await chai.request(app) + .put(`/api/conversation/${rootConvoUUID}`) + .set(constants.headers) + .send({ + body: 'test updated', + visibility: 'private' + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + + expect(res.body).to.haveOwnProperty('UUID') + expect(res.body.UUID).to.equal(rootConvoUUID) + + expect(res.body).to.haveOwnProperty('body') + expect(res.body.body).to.equal('test updated') + + expect(res.body).to.haveOwnProperty('visibility') + expect(res.body.visibility).to.equal('private') + }) + }) }) context('Negative Tests', () => { @@ -158,7 +180,53 @@ describe('Testing Conversation endpoints', () => { expect(res).to.have.status(400) expect(res.body).to.haveOwnProperty('message') - expect(res.body.message).to.equal('Missing required field body') + expect(res.body.message).to.equal("Parameters were invalid: conversation object must include property 'body' (string) and optionally 'visibility' ('public' or 'private').") + }) + }) + it('Should fail to post a conversation with invalid body', async () => { + await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ + body: 123 + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + + expect(res.body).to.haveOwnProperty('message') + expect(res.body.message).to.equal("Parameters were invalid: conversation object must include property 'body' (string) and optionally 'visibility' ('public' or 'private').") + }) + }) + it('Should fail to update a conversation that does not exist', async () => { + await chai.request(app) + .put('/api/conversation/non-existent-uuid') + .set(constants.headers) + .send({ + body: 'test updated', + visibility: 'private' + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(404) + + expect(res.body).to.haveOwnProperty('message') + expect(res.body.message).to.equal('The conversation with UUID non-existent-uuid does not exist.') + }) + }) + it('Should fail to update a conversation with invalid body', async () => { + await chai.request(app) + .put(`/api/conversation/${rootConvoUUID}`) + .set(constants.headers) + .send({ + body: 123 + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + + expect(res.body).to.haveOwnProperty('message') + expect(res.body.message).to.equal("Parameters were invalid: conversation object must include at least one of the following properties: 'body' (string) or 'visibility' ('public' or 'private').") }) }) }) diff --git a/test/integration-tests/conversation/editConversationTest.js b/test/integration-tests/conversation/editConversationTest.js index 08e101f6e..7a219c385 100644 --- a/test/integration-tests/conversation/editConversationTest.js +++ b/test/integration-tests/conversation/editConversationTest.js @@ -14,9 +14,8 @@ const orgAdminHeaders = { 'CVE-API-USER': 'activity_6_admin@activity_6.com' } -describe('Testing Conversation endpoints', () => { +describe('Testing Conversation edit by index endpoint', () => { let org - // let rootConvoUUID before(async () => { await chai @@ -181,7 +180,7 @@ describe('Testing Conversation endpoints', () => { expect(res).to.have.status(404) expect(res.body).to.haveOwnProperty('message') - expect(res.body.message).to.equal('The conversation at index 5 does not exist for the activity_6 organization.') + expect(res.body.message).to.equal('No conversation exists at index 5 for the activity_6 organization.') }) }) it('Should fail if admin tries to update a conversation they do not own', async () => {