diff --git a/api-docs/openapi.json b/api-docs/openapi.json index a2523b515..bf168434d 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -6179,6 +6179,112 @@ } } } + }, + "/notification": { + "get": { + "tags": [ + "Notification" + ], + "summary": "Retrieves notifications for the authenticated user", + "description": "", + "operationId": "getNotifications", + "responses": { + "200": { + "description": "Notifications ordered newest first", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/notification/list-notifications-response.json" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/notification/{uuid}": { + "delete": { + "tags": [ + "Notification" + ], + "summary": "Dismisses one notification for the authenticated user", + "description": "", + "operationId": "dismissNotification", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Notification dismissed" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Notification was not found for the authenticated user" + } + } + } + }, + "/notification/target/{user_uuid}": { + "post": { + "tags": [ + "Notification" + ], + "summary": "Creates a notification for an existing user (Secretariat only)", + "description": "", + "operationId": "createNotificationForUser", + "parameters": [ + { + "name": "user_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Notification created", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/notification/notification.json" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } } }, "components": { diff --git a/schemas/notification/list-notifications-response.json b/schemas/notification/list-notifications-response.json new file mode 100644 index 000000000..05118bc43 --- /dev/null +++ b/schemas/notification/list-notifications-response.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { "notifications": { "type": "array", "items": { "$ref": "notification.json" } } }, + "required": ["notifications"] +} diff --git a/schemas/notification/notification.json b/schemas/notification/notification.json new file mode 100644 index 000000000..6e038f3c6 --- /dev/null +++ b/schemas/notification/notification.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "UUID": { "type": "string" }, + "body": { "type": "string" }, + "links": { "type": "object", "properties": { "org_shortname": { "type": "string" }, "user_shortname": { "type": "string" } } }, + "type": { "enum": ["PRIVATE_MESSAGE", "PUBLIC_MESSAGE"] }, + "created_at": { "type": "string", "format": "date-time" } + }, + "required": ["UUID", "body", "links", "type", "created_at"] +} diff --git a/src/controller/notification.controller/index.js b/src/controller/notification.controller/index.js new file mode 100644 index 000000000..31c910ef5 --- /dev/null +++ b/src/controller/notification.controller/index.js @@ -0,0 +1,59 @@ +const router = require('express').Router() +const { body, param } = require('express-validator') +const mw = require('../../middleware/middleware') +const controller = require('./notification.controller') + +function parseError (req, res, next) { + const { validationResult } = require('express-validator') + const errors = validationResult(req) + if (!errors.isEmpty()) return res.status(400).json({ error: 'BAD_INPUT', message: 'Parameters were invalid', details: errors.array() }) + next() +} + +router.get('/notification', + /* + #swagger.tags = ['Notification'] + #swagger.operationId = 'getNotifications' + #swagger.summary = 'Retrieves notifications for the authenticated user' + #swagger.responses[200] = { description: 'Notifications ordered newest first', content: { 'application/json': { schema: { $ref: '../schemas/notification/list-notifications-response.json' } } } } + */ + mw.validateUser, + controller.getNotifications +) + +router.delete('/notification/:uuid', + /* + #swagger.tags = ['Notification'] + #swagger.operationId = 'dismissNotification' + #swagger.summary = 'Dismisses one notification for the authenticated user' + #swagger.responses[204] = { description: 'Notification dismissed' } + #swagger.responses[404] = { description: 'Notification was not found for the authenticated user' } + */ + mw.validateUser, + param('uuid').isUUID(4), + parseError, + (req, res, next) => { req.ctx.params = { uuid: req.params.uuid }; next() }, + controller.dismissNotification +) + +router.post('/notification/target/:user_uuid', + /* + #swagger.tags = ['Notification'] + #swagger.operationId = 'createNotificationForUser' + #swagger.summary = 'Creates a notification for an existing user (Secretariat only)' + #swagger.responses[201] = { description: 'Notification created', content: { 'application/json': { schema: { $ref: '../schemas/notification/notification.json' } } } } + */ + mw.validateUser, + mw.onlySecretariat, + param('user_uuid').isUUID(4), + body('body').isString().trim().notEmpty(), + body('type').isIn(['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE']), + body('links').optional().isObject(), + body('links.org_shortname').optional().isString(), + body('links.user_shortname').optional().isString(), + parseError, + (req, res, next) => { req.ctx.params = { user_uuid: req.params.user_uuid }; req.ctx.body = req.body; next() }, + controller.createNotificationForUser +) + +module.exports = router diff --git a/src/controller/notification.controller/notification.controller.js b/src/controller/notification.controller/notification.controller.js new file mode 100644 index 000000000..57f4469b7 --- /dev/null +++ b/src/controller/notification.controller/notification.controller.js @@ -0,0 +1,45 @@ +const authContext = require('../../utils/authContext') + +async function getNotifications (req, res, next) { + try { + const notificationRepo = req.ctx.repositories.getNotificationRepository() + const userRepo = req.ctx.repositories.getBaseUserRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const userUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) + return res.status(200).json({ notifications: await notificationRepo.getNotifications(userUUID) }) + } catch (err) { + next(err) + } +} + +async function dismissNotification (req, res, next) { + try { + const notificationRepo = req.ctx.repositories.getNotificationRepository() + const userRepo = req.ctx.repositories.getBaseUserRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const userUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) + const dismissed = await notificationRepo.dismissNotification(userUUID, req.ctx.params.uuid) + if (!dismissed) return res.status(404).json({ error: 'NOTIFICATION_DNE', message: 'Notification does not exist.' }) + return res.status(204).send() + } catch (err) { + next(err) + } +} + +async function createNotificationForUser (req, res, next) { + try { + const notificationRepo = req.ctx.repositories.getNotificationRepository() + const userRepo = req.ctx.repositories.getBaseUserRepository() + const recipient = await userRepo.findUserByUUID(req.ctx.params.user_uuid) + if (!recipient) return res.status(404).json({ error: 'USER_DNE', message: 'User does not exist.' }) + if (!notificationRepo.validateNotification(req.ctx.body)) { + return res.status(400).json({ error: 'BAD_INPUT', message: 'Notification body, links, or type is invalid.' }) + } + const notification = await notificationRepo.addNotification(recipient.UUID, req.ctx.body) + return res.status(201).json(notification) + } catch (err) { + next(err) + } +} + +module.exports = { getNotifications, dismissNotification, createNotificationForUser } diff --git a/src/model/notification.js b/src/model/notification.js new file mode 100644 index 000000000..a9ed6af5e --- /dev/null +++ b/src/model/notification.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose') + +const NotificationSchema = new mongoose.Schema({ + user_uuid: { type: String, required: true, unique: true, index: true }, + notifications: [{ + _id: false, + UUID: { type: String, required: true }, + body: { type: String, required: true }, + links: { + _id: false, + org_shortname: { type: String, default: '' }, + user_shortname: { type: String, default: '' } + }, + type: { type: String, enum: ['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE'], required: true }, + created_at: { type: Date, required: true } + }] +}, { collection: 'Notification' }) + +NotificationSchema.index({ user_uuid: 1, 'notifications.created_at': -1 }) + +module.exports = mongoose.model('Notification', NotificationSchema) diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 36095841c..b06491534 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -162,7 +162,6 @@ function normalizeProgramDataStatus (org) { const normalizedStatus = PROGRAM_DATA_STATUS_BY_LOWERCASE.get(status.toLowerCase()) if (normalizedStatus) org.program_data.status = normalizedStatus } - function isResponseExtensionField (key) { return key.startsWith('_') && !INTERNAL_UNDERSCORE_FIELDS.includes(key) } @@ -512,6 +511,15 @@ class BaseOrgRepository extends BaseRepository { ).lean() } + async findSecretariatUserUUIDs (options = {}) { + const orgs = await BaseOrgModel.find( + { authority: 'SECRETARIAT' }, + { _id: 0, users: 1 }, + options + ).lean() + return [...new Set(orgs.flatMap(org => Array.isArray(org.users) ? org.users : []))] + } + /** * @function hasRole * @description Checks if an organization object has the requested role. diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index 4160a8bbc..8060a1abb 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -216,6 +216,11 @@ class BaseUserRepository extends BaseRepository { ).lean() } + async findActiveUsersByUUIDs (uuids, options = {}) { + if (!Array.isArray(uuids) || uuids.length === 0) return [] + return await BaseUser.find({ UUID: { $in: uuids }, status: 'active' }, { _id: 0, UUID: 1 }, options).lean() + } + /** * @async * @function isUserAdminOfOrgUUID diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index 82c902733..4e245477d 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -112,6 +112,29 @@ class ConversationRepository extends BaseRepository { const newConversation = new ConversationModel(conversationObj) const result = await newConversation.save(options) + const BaseOrgRepository = require('./baseOrgRepository') + const BaseUserRepository = require('./baseUserRepository') + const NotificationRepository = require('./notificationRepository') + const orgRepo = new BaseOrgRepository() + const userRepo = new BaseUserRepository() + const notificationRepo = new NotificationRepository() + const targetOrg = await orgRepo.findOneByUUID(targetUUID, options, false, { UUID: 1, short_name: 1, users: 1 }) + const targetUser = targetOrg ? null : await userRepo.findUserByUUID(targetUUID, options) + let recipientUUIDs = await orgRepo.findSecretariatUserUUIDs(options) + if (result.visibility === 'public' && isSecretariat) { + const targetRecipientUUIDs = targetOrg?.users || (targetUser ? [targetUser.UUID] : []) + recipientUUIDs = [...recipientUUIDs, ...targetRecipientUUIDs] + } + if (isSecretariat) { + recipientUUIDs = recipientUUIDs.filter(recipientUUID => recipientUUID !== user.UUID) + } + const activeRecipients = await userRepo.findActiveUsersByUUIDs(recipientUUIDs, options) + await notificationRepo.addNotifications(activeRecipients.map(recipient => recipient.UUID), { + body: result.body, + links: { org_shortname: targetOrg?.short_name || '', user_shortname: user.username || '' }, + type: result.visibility === 'private' ? 'PRIVATE_MESSAGE' : 'PUBLIC_MESSAGE' + }, options) + const rawObject = result.toObject() delete rawObject._id diff --git a/src/repositories/notificationRepository.js b/src/repositories/notificationRepository.js new file mode 100644 index 000000000..159ad8bf5 --- /dev/null +++ b/src/repositories/notificationRepository.js @@ -0,0 +1,61 @@ +const uuid = require('uuid') +const NotificationModel = require('../model/notification') +const BaseRepository = require('./baseRepository') + +class NotificationRepository extends BaseRepository { + constructor () { + super(NotificationModel) + } + + validateNotification (notification) { + return notification && + typeof notification.body === 'string' && notification.body.trim().length > 0 && + ['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE'].includes(notification.type) && + (!notification.links || (typeof notification.links === 'object' && !Array.isArray(notification.links) && + (!notification.links.org_shortname || typeof notification.links.org_shortname === 'string') && + (!notification.links.user_shortname || typeof notification.links.user_shortname === 'string'))) + } + + createNotification (notification) { + return { + UUID: uuid.v4(), + body: notification.body, + links: { + org_shortname: notification.links?.org_shortname || '', + user_shortname: notification.links?.user_shortname || '' + }, + type: notification.type, + created_at: new Date() + } + } + + async addNotification (userUUID, notification, options = {}) { + const createdNotification = this.createNotification(notification) + await NotificationModel.updateOne( + { user_uuid: userUUID }, + { $push: { notifications: createdNotification } }, + { upsert: true, ...options } + ) + return createdNotification + } + + async addNotifications (userUUIDs, notification, options = {}) { + return Promise.all([...new Set(userUUIDs)].map(userUUID => this.addNotification(userUUID, notification, options))) + } + + async getNotifications (userUUID, options = {}) { + const result = await NotificationModel.findOne({ user_uuid: userUUID }, null, options).lean() + return (result?.notifications || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + } + + async dismissNotification (userUUID, notificationUUID, options = {}) { + const result = await NotificationModel.updateOne( + { user_uuid: userUUID, 'notifications.UUID': notificationUUID }, + { $pull: { notifications: { UUID: notificationUUID } } }, + options + ) + return result.modifiedCount === 1 + } +} + +module.exports = NotificationRepository diff --git a/src/repositories/repositoryFactory.js b/src/repositories/repositoryFactory.js index 7f97e1177..99ad8e3c0 100644 --- a/src/repositories/repositoryFactory.js +++ b/src/repositories/repositoryFactory.js @@ -8,6 +8,7 @@ const BaseUserRepository = require('./baseUserRepository') const ConversationRepository = require('./conversationRepository') const ReviewObjectRepository = require('./reviewObjectRepository') const GlossaryRepository = require('./glossaryRepository') +const NotificationRepository = require('./notificationRepository') class RepositoryFactory { getOrgRepository () { @@ -60,6 +61,10 @@ class RepositoryFactory { return repo } + getNotificationRepository () { + return new NotificationRepository() + } + getAuditRepository () { const AuditRepository = require('./auditRepository') const repo = new AuditRepository() diff --git a/src/routes.config.js b/src/routes.config.js index b914e243c..344a8b798 100644 --- a/src/routes.config.js +++ b/src/routes.config.js @@ -12,6 +12,7 @@ const AuditController = require('./controller/audit.controller') const ConversationController = require('./controller/conversation.controller') const ReviewObjectController = require('./controller/review-object.controller') const GlossaryController = require('./controller/glossary.controller') +const NotificationController = require('./controller/notification.controller') var options = { swaggerOptions: { @@ -39,6 +40,7 @@ module.exports = async function configureRoutes (app) { app.use('/api/', ConversationController) app.use('/api/', ReviewObjectController) app.use('/api/', GlossaryController) + app.use('/api/', NotificationController) app.get('/api-docs/openapi.json', (req, res) => res.json(openApiSpecification)) app.use('/api-docs', swaggerUi.serveFiles(null, options), swaggerUi.setup(null, setupOptions)) app.use('/schemas/', SchemasController) diff --git a/src/swagger.js b/src/swagger.js index 2375b8c08..a75851c8a 100644 --- a/src/swagger.js +++ b/src/swagger.js @@ -8,7 +8,8 @@ const endpointsFiles = [ 'src/controller/system.controller/index.js', 'src/controller/registry.controller/index.js', 'src/controller/conversation.controller/index.js', - 'src/controller/review-object.controller/index.js' + 'src/controller/review-object.controller/index.js', + 'src/controller/notification.controller/index.js' ] const publishedCVERecord = require('../schemas/cve/published-cve-example.json') const rejectedCVERecord = require('../schemas/cve/rejected-cve-example.json') diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js new file mode 100644 index 000000000..80158c8fb --- /dev/null +++ b/test/integration-tests/notification/notificationTest.js @@ -0,0 +1,303 @@ +/* eslint-disable no-unused-expressions */ + +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-http')) + +const constants = require('../constants.js') +const app = require('../../../src/index.js') +const NotificationModel = require('../../../src/model/notification') + +const otherSecretariatHeaders = { + ...constants.headers, + 'CVE-API-USER': 'cps@mitre.org' +} + +describe('Testing Notification endpoints', () => { + let orgUUID + let recipientUUID + let automaticNotificationUUID + let conversationUUID + + before(async () => { + const orgResponse = await chai.request(app).get('/api/registry/org/win_5').set(constants.headers) + expect(orgResponse).to.have.status(200) + orgUUID = orgResponse.body.UUID + + const userResponse = await chai.request(app) + .get('/api/registry/org/win_5/user/win_5_admin@win_5.com') + .set(constants.headers) + expect(userResponse).to.have.status(200) + recipientUUID = userResponse.body.UUID + }) + + after(async () => { + await NotificationModel.deleteMany({ user_uuid: recipientUUID }) + }) + + it('notifies target organization users and other Secretariat users about a public Secretariat message', async () => { + const messageBody = 'notification integration test\nPlease review the "contact" details & confirm they are current.' + const conversationResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ body: messageBody, visibility: 'public' }) + expect(conversationResponse).to.have.status(200) + conversationUUID = conversationResponse.body.UUID + + const notificationResponse = await chai.request(app) + .get('/api/notification') + .set(constants.nonSecretariatUserHeaders2) + expect(notificationResponse).to.have.status(200) + expect(notificationResponse.body.notifications).to.be.an('array').that.is.not.empty + + const notification = notificationResponse.body.notifications.find(item => item.body === messageBody) + expect(notification).to.exist + expect(notification).to.include({ type: 'PUBLIC_MESSAGE' }) + expect(notification.links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'test_secretariat_0@mitre.org' }) + automaticNotificationUUID = notification.UUID + + const otherSecretariatResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + expect(otherSecretariatResponse).to.have.status(200) + expect(senderResponse).to.have.status(200) + const otherNotifications = otherSecretariatResponse.body.notifications.filter(item => item.body === messageBody) + expect(otherNotifications).to.have.lengthOf(1) + expect(otherNotifications[0].type).to.equal('PUBLIC_MESSAGE') + expect(otherNotifications[0].links).to.deep.equal(notification.links) + expect(senderResponse.body.notifications.some(item => item.body === messageBody)).to.equal(false) + }) + + it('allows a user to dismiss only their own notification', async () => { + const dismissResponse = await chai.request(app) + .delete(`/api/notification/${automaticNotificationUUID}`) + .set(constants.nonSecretariatUserHeaders2) + expect(dismissResponse).to.have.status(204) + + const missingResponse = await chai.request(app) + .delete(`/api/notification/${automaticNotificationUUID}`) + .set(constants.nonSecretariatUserHeaders) + expect(missingResponse).to.have.status(404) + }) + + it('notifies an individual target user when a conversation targets their UUID', async () => { + const response = await chai.request(app) + .post(`/api/conversation/target/${recipientUUID}`) + .set(constants.headers) + .send({ body: 'individual notification integration test', visibility: 'public' }) + expect(response).to.have.status(200) + + const notificationsResponse = await chai.request(app) + .get('/api/notification') + .set(constants.nonSecretariatUserHeaders2) + expect(notificationsResponse).to.have.status(200) + const notification = notificationsResponse.body.notifications.find(item => item.body === 'individual notification integration test') + expect(notification).to.exist + expect(notification.type).to.equal('PUBLIC_MESSAGE') + expect(notification.links).to.deep.equal({ org_shortname: '', user_shortname: 'test_secretariat_0@mitre.org' }) + + const otherSecretariatResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + expect(otherSecretariatResponse).to.have.status(200) + expect(senderResponse).to.have.status(200) + expect(otherSecretariatResponse.body.notifications.filter(item => item.body === notification.body)).to.have.lengthOf(1) + expect(senderResponse.body.notifications.some(item => item.body === notification.body)).to.equal(false) + }) + + it('notifies Secretariat users, rather than CNA members, about CNA-authored conversations', async () => { + const recipientBefore = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + const secretariatBefore = await chai.request(app).get('/api/notification').set(constants.headers) + expect(recipientBefore).to.have.status(200) + expect(secretariatBefore).to.have.status(200) + + const response = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.nonSecretariatUserHeaders2) + .send({ body: 'CNA notification integration test' }) + expect(response).to.have.status(200) + + const recipientAfter = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + const secretariatAfter = await chai.request(app).get('/api/notification').set(constants.headers) + expect(recipientAfter).to.have.status(200) + expect(secretariatAfter).to.have.status(200) + expect(recipientAfter.body.notifications).to.have.lengthOf(recipientBefore.body.notifications.length) + expect(secretariatAfter.body.notifications).to.have.lengthOf(secretariatBefore.body.notifications.length + 1) + const notification = secretariatAfter.body.notifications.find(item => item.body === 'CNA notification integration test') + expect(notification).to.exist + expect(notification.type).to.equal('PUBLIC_MESSAGE') + expect(notification.links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'win_5_admin@win_5.com' }) + }) + + it('notifies the CNA and other Secretariat users when Secretariat replies to a public CNA message', async () => { + const messageResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.nonSecretariatUserHeaders2) + .send({ body: 'CNA message awaiting a Secretariat reply' }) + expect(messageResponse).to.have.status(200) + + const replyBody = 'Public Secretariat reply notification integration test' + const replyResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ body: replyBody, visibility: 'public' }) + expect(replyResponse).to.have.status(200) + + for (const headers of [constants.nonSecretariatUserHeaders2, otherSecretariatHeaders]) { + const response = await chai.request(app).get('/api/notification').set(headers) + expect(response).to.have.status(200) + const notifications = response.body.notifications.filter(item => item.body === replyBody) + expect(notifications).to.have.lengthOf(1) + expect(notifications[0].type).to.equal('PUBLIC_MESSAGE') + expect(notifications[0].links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'test_secretariat_0@mitre.org' }) + } + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + expect(senderResponse).to.have.status(200) + expect(senderResponse.body.notifications.some(item => item.body === replyBody)).to.equal(false) + }) + + it('excludes the sender and avoids duplicates when a public message targets a Secretariat organization', async () => { + const orgResponse = await chai.request(app).get('/api/registry/org/mitre').set(constants.headers) + expect(orgResponse).to.have.status(200) + const messageBody = 'Public Secretariat organization notification integration test' + const messageResponse = await chai.request(app) + .post(`/api/conversation/target/${orgResponse.body.UUID}`) + .set(constants.headers) + .send({ body: messageBody, visibility: 'public' }) + expect(messageResponse).to.have.status(200) + + const otherSecretariatResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + expect(otherSecretariatResponse).to.have.status(200) + expect(senderResponse).to.have.status(200) + const notifications = otherSecretariatResponse.body.notifications.filter(item => item.body === messageBody) + expect(notifications).to.have.lengthOf(1) + expect(notifications[0].type).to.equal('PUBLIC_MESSAGE') + expect(notifications[0].links).to.deep.equal({ org_shortname: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) + expect(senderResponse.body.notifications.some(item => item.body === messageBody)).to.equal(false) + }) + + it('does not create a notification when an existing conversation is edited', async () => { + const beforeResponse = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + expect(beforeResponse).to.have.status(200) + + const editResponse = await chai.request(app) + .put(`/api/conversation/${conversationUUID}`) + .set(constants.headers) + .send({ body: 'notification integration test edited' }) + expect(editResponse).to.have.status(200) + + const afterResponse = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + expect(afterResponse).to.have.status(200) + expect(afterResponse.body.notifications).to.have.lengthOf(beforeResponse.body.notifications.length) + }) + + it('notifies Secretariat users, but not target organization users, about private conversations', async () => { + const recipientBefore = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + const otherSecretariatBefore = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(recipientBefore).to.have.status(200) + expect(otherSecretariatBefore).to.have.status(200) + + const privateConversationResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ body: 'private notification integration test', visibility: 'private' }) + expect(privateConversationResponse).to.have.status(200) + + const recipientAfter = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + expect(recipientAfter).to.have.status(200) + expect(recipientAfter.body.notifications).to.have.lengthOf(recipientBefore.body.notifications.length) + + const secretariatResponse = await chai.request(app).get('/api/notification').set(constants.headers) + const otherSecretariatAfter = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(secretariatResponse).to.have.status(200) + expect(otherSecretariatAfter).to.have.status(200) + const privateNotificationBody = 'private notification integration test' + expect(secretariatResponse.body.notifications.some(item => item.body === privateNotificationBody)).to.equal(false) + expect(recipientAfter.body.notifications.some(item => item.body === privateNotificationBody)).to.equal(false) + expect(otherSecretariatAfter.body.notifications).to.have.lengthOf(otherSecretariatBefore.body.notifications.length + 1) + const notification = otherSecretariatAfter.body.notifications.find(item => item.body === privateNotificationBody) + expect(notification).to.exist + expect(notification.type).to.equal('PRIVATE_MESSAGE') + expect(notification.links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'test_secretariat_0@mitre.org' }) + }) + + for (const visibility of ['private', 'public']) { + it(`creates notifications for ${visibility} conversations submitted through registry organization updates`, async () => { + const orgResponse = await chai.request(app).get('/api/registry/org/win_5').set(constants.headers) + expect(orgResponse).to.have.status(200) + const org = orgResponse.body + delete org.created + delete org.last_updated + delete org.admins + delete org.users + delete org.top_level_root + delete org.oversees + delete org.reports_to + delete org._hierarchy + delete org.program_data + + const beforeResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(beforeResponse).to.have.status(200) + + const messageBody = `${visibility} registry update notification integration test` + const conversation = { body: messageBody } + if (visibility === 'public') conversation.visibility = 'public' + const updateResponse = await chai.request(app) + .put('/api/registry/org/win_5') + .set(constants.headers) + .send({ ...org, conversation }) + expect(updateResponse).to.have.status(200) + + const afterResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(afterResponse).to.have.status(200) + expect(afterResponse.body.notifications).to.have.lengthOf(beforeResponse.body.notifications.length + 1) + const notification = afterResponse.body.notifications.find(item => item.body === messageBody) + expect(notification).to.exist + expect(notification.type).to.equal(visibility === 'public' ? 'PUBLIC_MESSAGE' : 'PRIVATE_MESSAGE') + expect(notification.links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'test_secretariat_0@mitre.org' }) + + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + const targetResponse = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + expect(senderResponse).to.have.status(200) + expect(targetResponse).to.have.status(200) + expect(senderResponse.body.notifications.some(item => item.body === messageBody)).to.equal(false) + expect(targetResponse.body.notifications.filter(item => item.body === messageBody)).to.have.lengthOf(visibility === 'public' ? 1 : 0) + }) + } + + it('allows Secretariat to create a generic notification for an existing user', async () => { + const response = await chai.request(app) + .post(`/api/notification/target/${recipientUUID}`) + .set(constants.headers) + .send({ + body: 'generic notification integration test', + links: { user_shortname: 'win_5_admin@win_5.com' }, + type: 'PUBLIC_MESSAGE' + }) + expect(response).to.have.status(201) + expect(response.body).to.include({ body: 'generic notification integration test', type: 'PUBLIC_MESSAGE' }) + expect(response.body).to.have.property('UUID') + expect(response.body).to.have.property('created_at') + }) + + it('denies generic notification creation to non-Secretariat users', async () => { + const response = await chai.request(app) + .post(`/api/notification/target/${recipientUUID}`) + .set(constants.nonSecretariatUserHeaders2) + .send({ body: 'denied', type: 'PUBLIC_MESSAGE' }) + expect(response).to.have.status(403) + }) + + it('rejects invalid generic notification payloads and nonexistent recipients', async () => { + const invalidResponse = await chai.request(app) + .post(`/api/notification/target/${recipientUUID}`) + .set(constants.headers) + .send({ body: '', type: 'OTHER' }) + expect(invalidResponse).to.have.status(400) + + const missingUserResponse = await chai.request(app) + .post('/api/notification/target/00000000-0000-4000-8000-000000000000') + .set(constants.headers) + .send({ body: 'missing user', type: 'PUBLIC_MESSAGE' }) + expect(missingUserResponse).to.have.status(404) + }) +}) diff --git a/test/unit-tests/conversation/conversationRepositoryTest.js b/test/unit-tests/conversation/conversationRepositoryTest.js index d8cab9b19..915305451 100644 --- a/test/unit-tests/conversation/conversationRepositoryTest.js +++ b/test/unit-tests/conversation/conversationRepositoryTest.js @@ -5,8 +5,21 @@ const expect = chai.expect const ConversationModel = require('../../../src/model/conversation') const ConversationRepository = require('../../../src/repositories/conversationRepository') +const BaseOrgRepository = require('../../../src/repositories/baseOrgRepository') +const BaseUserRepository = require('../../../src/repositories/baseUserRepository') +const NotificationRepository = require('../../../src/repositories/notificationRepository') describe('Testing Conversation Repository', () => { + beforeEach(() => { + // Conversation creation also resolves notification recipients and writes notifications. + // Stub those dependencies so repository tests do not require a database connection. + sinon.stub(BaseOrgRepository.prototype, 'findOneByUUID').resolves(null) + sinon.stub(BaseOrgRepository.prototype, 'findSecretariatUserUUIDs').resolves([]) + sinon.stub(BaseUserRepository.prototype, 'findUserByUUID').resolves(null) + sinon.stub(BaseUserRepository.prototype, 'findActiveUsersByUUIDs').resolves([]) + sinon.stub(NotificationRepository.prototype, 'addNotifications').resolves([]) + }) + afterEach(() => { sinon.restore() }) @@ -59,6 +72,49 @@ describe('Testing Conversation Repository', () => { expect(result.author_role).to.equal('Partner') }) + for (const isReply of [false, true]) { + it(`notifies the target and other active Secretariat users for a public ${isReply ? 'reply' : 'first message'}`, async () => { + const latestConversation = isReply ? { UUID: 'previous-message', save: sinon.stub().resolves() } : null + sinon.stub(ConversationModel, 'findOne').resolves(latestConversation) + sinon.stub(ConversationModel.prototype, 'save').callsFake(async function () { + return this + }) + BaseOrgRepository.prototype.findOneByUUID.resolves({ + UUID: 'target-uuid', + short_name: 'target-org', + users: ['target-user'] + }) + BaseOrgRepository.prototype.findSecretariatUserUUIDs.resolves([ + 'sender', 'other-secretariat', 'another-secretariat', 'inactive-secretariat' + ]) + const activeRecipientUUIDs = ['other-secretariat', 'another-secretariat', 'target-user'] + BaseUserRepository.prototype.findActiveUsersByUUIDs.resolves(activeRecipientUUIDs.map(UUID => ({ UUID }))) + const options = { session: {} } + + const repo = new ConversationRepository() + const result = await repo.createConversation( + 'target-uuid', + { body: 'Public Secretariat message', visibility: 'public' }, + { UUID: 'sender', username: 'sender@example.org' }, + true, + options + ) + + sinon.assert.calledOnceWithExactly(BaseOrgRepository.prototype.findSecretariatUserUUIDs, options) + sinon.assert.calledOnceWithExactly(BaseUserRepository.prototype.findActiveUsersByUUIDs, + ['other-secretariat', 'another-secretariat', 'inactive-secretariat', 'target-user'], options) + sinon.assert.calledOnceWithExactly(NotificationRepository.prototype.addNotifications, activeRecipientUUIDs, { + body: 'Public Secretariat message', + links: { org_shortname: 'target-org', user_shortname: 'sender@example.org' }, + type: 'PUBLIC_MESSAGE' + }, options) + if (isReply) { + expect(latestConversation.next_conversation_uuid).to.equal(result.UUID) + sinon.assert.calledOnceWithExactly(latestConversation.save, options) + } + }) + } + it('normalizes stored Secretariat author names when conversations are returned to Secretariat', async () => { sinon.stub(ConversationModel, 'find').returns({ lean: sinon.stub().resolves([