From 87b60c23e83422753d83b1ea191baf6cb731ee0a Mon Sep 17 00:00:00 2001 From: Leo Williamson Date: Wed, 12 Aug 2026 14:02:56 -0400 Subject: [PATCH 1/9] Validate registry organization program status --- schemas/registry-org/BaseOrg.json | 3 +- .../create-registry-org-request.json | 3 +- .../get-registry-org-response.json | 3 +- .../list-registry-orgs-response.json | 3 +- .../update-registry-org-request.json | 3 +- src/model/baseorg.js | 2 +- test/unit-tests/org/baseOrgRepositoryTest.js | 39 +++++++++++++++++++ 7 files changed, 50 insertions(+), 6 deletions(-) diff --git a/schemas/registry-org/BaseOrg.json b/schemas/registry-org/BaseOrg.json index 01956a1ef..0bc4aa126 100644 --- a/schemas/registry-org/BaseOrg.json +++ b/schemas/registry-org/BaseOrg.json @@ -185,7 +185,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["active", "inactive", "pending"] } }, "additionalProperties": false diff --git a/schemas/registry-org/create-registry-org-request.json b/schemas/registry-org/create-registry-org-request.json index 9278be8fa..78b678e50 100644 --- a/schemas/registry-org/create-registry-org-request.json +++ b/schemas/registry-org/create-registry-org-request.json @@ -194,7 +194,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["active", "inactive", "pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/get-registry-org-response.json b/schemas/registry-org/get-registry-org-response.json index ae40b965c..4efbaafe4 100644 --- a/schemas/registry-org/get-registry-org-response.json +++ b/schemas/registry-org/get-registry-org-response.json @@ -159,7 +159,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["active", "inactive", "pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/list-registry-orgs-response.json b/schemas/registry-org/list-registry-orgs-response.json index 84f3f4b80..91df59b00 100644 --- a/schemas/registry-org/list-registry-orgs-response.json +++ b/schemas/registry-org/list-registry-orgs-response.json @@ -181,7 +181,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["active", "inactive", "pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/update-registry-org-request.json b/schemas/registry-org/update-registry-org-request.json index 80d4eed99..a4c6b93d3 100644 --- a/schemas/registry-org/update-registry-org-request.json +++ b/schemas/registry-org/update-registry-org-request.json @@ -209,7 +209,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["active", "inactive", "pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/src/model/baseorg.js b/src/model/baseorg.js index 0a720f8f3..669d3cfce 100644 --- a/src/model/baseorg.js +++ b/src/model/baseorg.js @@ -41,7 +41,7 @@ const schema = { cve_website_update_needed: Boolean, partner_active_date: String, partner_inactive_date: String, - status: String + status: { type: String, enum: ['active', 'inactive', 'pending'] } }, advisory_locations: [String], advisory_location_require_credentials: Boolean, diff --git a/test/unit-tests/org/baseOrgRepositoryTest.js b/test/unit-tests/org/baseOrgRepositoryTest.js index f14244ac0..af7b9a632 100644 --- a/test/unit-tests/org/baseOrgRepositoryTest.js +++ b/test/unit-tests/org/baseOrgRepositoryTest.js @@ -69,4 +69,43 @@ describe('Testing BaseOrgRepository', () => { select ]) }) + + it('accepts every supported program data status', () => { + const repository = new BaseOrgRepository() + + for (const status of ['active', 'inactive', 'pending']) { + const org = { + short_name: 'example-org', + id_quota: 1, + authority: ['CNA'], + program_data: { status } + } + + expect(repository.validateOrg(org).isValid, status).to.equal(true) + expect(new BaseOrgModel({ program_data: { status } }).validateSync(), status).to.equal(undefined) + } + }) + + it('rejects an unsupported program data status', () => { + const repository = new BaseOrgRepository() + const org = { + short_name: 'example-org', + id_quota: 1, + authority: ['CNA'], + program_data: { status: 'archived' } + } + + const schemaResult = repository.validateOrg(org) + expect(schemaResult.isValid).to.equal(false) + expect(schemaResult.errors).to.deep.include({ + instancePath: '/program_data/status', + schemaPath: '/BaseOrg#/properties/program_data/properties/status/enum', + keyword: 'enum', + params: { allowedValues: ['active', 'inactive', 'pending'] }, + message: 'must be equal to one of the allowed values' + }) + + const validationError = new BaseOrgModel({ program_data: { status: 'archived' } }).validateSync() + expect(validationError.errors['program_data.status'].kind).to.equal('enum') + }) }) From a26f7dc2230a15d22f111e7cedcf0054444d8a57 Mon Sep 17 00:00:00 2001 From: James Dalphond Date: Wed, 9 Sep 2026 11:00:37 -0400 Subject: [PATCH 2/9] Use title-case registry organization statuses --- schemas/registry-org/BaseOrg.json | 2 +- .../create-registry-org-request.json | 2 +- .../get-registry-org-response.json | 2 +- .../list-registry-orgs-response.json | 2 +- .../update-registry-org-request.json | 2 +- src/model/baseorg.js | 2 +- src/repositories/baseOrgRepository.js | 22 +++++++++++++++---- .../registry-org/registryOrgCRUDTest.js | 22 ++++++++++++------- test/unit-tests/org/baseOrgRepositoryTest.js | 21 ++++++++++++++++-- 9 files changed, 57 insertions(+), 20 deletions(-) diff --git a/schemas/registry-org/BaseOrg.json b/schemas/registry-org/BaseOrg.json index 0bc4aa126..778ec22c5 100644 --- a/schemas/registry-org/BaseOrg.json +++ b/schemas/registry-org/BaseOrg.json @@ -186,7 +186,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "enum": ["Active", "Inactive", "Pending"] } }, "additionalProperties": false diff --git a/schemas/registry-org/create-registry-org-request.json b/schemas/registry-org/create-registry-org-request.json index 78b678e50..11a5137b6 100644 --- a/schemas/registry-org/create-registry-org-request.json +++ b/schemas/registry-org/create-registry-org-request.json @@ -195,7 +195,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/get-registry-org-response.json b/schemas/registry-org/get-registry-org-response.json index 4efbaafe4..7db85b0ea 100644 --- a/schemas/registry-org/get-registry-org-response.json +++ b/schemas/registry-org/get-registry-org-response.json @@ -160,7 +160,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/list-registry-orgs-response.json b/schemas/registry-org/list-registry-orgs-response.json index 91df59b00..b0de5e240 100644 --- a/schemas/registry-org/list-registry-orgs-response.json +++ b/schemas/registry-org/list-registry-orgs-response.json @@ -182,7 +182,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/update-registry-org-request.json b/schemas/registry-org/update-registry-org-request.json index a4c6b93d3..6d88c9071 100644 --- a/schemas/registry-org/update-registry-org-request.json +++ b/schemas/registry-org/update-registry-org-request.json @@ -210,7 +210,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/src/model/baseorg.js b/src/model/baseorg.js index 669d3cfce..18a752cb4 100644 --- a/src/model/baseorg.js +++ b/src/model/baseorg.js @@ -41,7 +41,7 @@ const schema = { cve_website_update_needed: Boolean, partner_active_date: String, partner_inactive_date: String, - status: { type: String, enum: ['active', 'inactive', 'pending'] } + status: { type: String, enum: ['Active', 'Inactive', 'Pending'] } }, advisory_locations: [String], advisory_location_require_credentials: Boolean, diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 076bc38dc..50e1cd06e 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -69,6 +69,19 @@ function decorateCnaRelationships (activeOrgs, hierarchyOrgs) { }) } +const PROGRAM_DATA_STATUS_BY_LOWERCASE = new Map([ + ['active', 'Active'], + ['inactive', 'Inactive'], + ['pending', 'Pending'] +]) + +function normalizeProgramDataStatus (org) { + const status = org?.program_data?.status + if (typeof status !== 'string') return + + 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) } @@ -851,16 +864,16 @@ class BaseOrgRepository extends BaseRepository { registryObjectRaw.program_data = {} } - // Default to 'inactive' if not provided + // Default to 'Inactive' if not provided if (!registryObjectRaw.program_data.status) { - registryObjectRaw.program_data.status = 'inactive' + registryObjectRaw.program_data.status = 'Inactive' } - if (registryObjectRaw.program_data.status === 'active') { + if (registryObjectRaw.program_data.status === 'Active') { registryObjectRaw.program_data.partner_active_date = new Date().toISOString().split('T')[0] // ensure inactive is not set delete registryObjectRaw.program_data.partner_inactive_date - } else if (registryObjectRaw.program_data.status === 'inactive') { + } else if (registryObjectRaw.program_data.status === 'Inactive') { registryObjectRaw.program_data.partner_inactive_date = new Date().toISOString().split('T')[0] // ensure active is not set delete registryObjectRaw.program_data.partner_active_date @@ -1322,6 +1335,7 @@ class BaseOrgRepository extends BaseRepository { */ validateOrg (org) { normalizeOrgCveWebsiteUpdateDate(org) + normalizeProgramDataStatus(org) if (!org.authority || (Array.isArray(org.authority) && org.authority.length === 0)) { return { isValid: false, errors: [{ instancePath: '/authority', message: 'authority is required' }] } diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 3dfb2e3f9..08e15bf59 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -90,7 +90,7 @@ describe('Testing /registry/org endpoints', () => { expect(res.body.created.is_last_resort).to.equal(true) expect(res.body.created).to.haveOwnProperty('program_data') - expect(res.body.created.program_data.status).to.equal('inactive') + expect(res.body.created.program_data.status).to.equal('Inactive') expect(res.body.created.program_data).to.haveOwnProperty('partner_inactive_date') expect(res.body.created.program_data).to.not.haveOwnProperty('partner_active_date') @@ -123,7 +123,7 @@ describe('Testing /registry/org endpoints', () => { expect(res.body.message).to.equal(orgWithProgramData.short_name + ' organization was successfully created.') expect(res.body.created).to.haveOwnProperty('program_data') - expect(res.body.created.program_data.status).to.equal('active') + expect(res.body.created.program_data.status).to.equal('Active') expect(res.body.created.program_data).to.haveOwnProperty('partner_active_date') expect(res.body.created.program_data).to.not.haveOwnProperty('advisory_location_require_credentials') expect(res.body.created.program_data).to.not.haveOwnProperty('vulnerability_advisory_location_for_web_scraping') @@ -734,7 +734,12 @@ describe('Testing /registry/org endpoints', () => { expect(res.body.disabled).to.equal(false) }) }) - it('Allows Secretariat to update program_data', async () => { + it('Allows Secretariat to update program_data and updates its timestamp', async () => { + const beforeUpdate = await chai.request(app) + .get('/api/registry/org/registry_org_test') + .set(secretariatHeaders) + expect(beforeUpdate).to.have.status(200) + const beforeUpdateTimestamp = Date.parse(beforeUpdate.body.last_updated) const partnerActiveDate = '2024-01-15' const cveWebsiteUpdateDate = '2024-04-10T18:30:00.000Z' await chai.request(app) @@ -754,7 +759,8 @@ describe('Testing /registry/org endpoints', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.updated).to.haveOwnProperty('program_data') - expect(res.body.updated.program_data.status).to.equal('active') + expect(res.body.updated.program_data.status).to.equal('Active') + expect(Date.parse(res.body.updated.last_updated)).to.be.greaterThan(beforeUpdateTimestamp) expect(res.body.updated.program_data).to.haveOwnProperty('partner_active_date') expect(res.body.updated.program_data.partner_active_date).to.equal(partnerActiveDate) expect(res.body.updated.program_data.cve_website_update_date).to.equal('2024-04-10') @@ -780,7 +786,7 @@ describe('Testing /registry/org endpoints', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.updated).to.haveOwnProperty('program_data') - expect(res.body.updated.program_data.status).to.equal('active') + expect(res.body.updated.program_data.status).to.equal('Active') expect(res.body.updated.program_data.partner_active_date).to.equal(partnerActiveDate) }) }) @@ -818,7 +824,7 @@ describe('Testing /registry/org endpoints', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.updated).to.haveOwnProperty('program_data') - expect(res.body.updated.program_data.status).to.equal('inactive') + expect(res.body.updated.program_data.status).to.equal('Inactive') expect(res.body.updated.program_data.partner_inactive_date).to.equal(partnerInactiveDate) }) }) @@ -835,7 +841,7 @@ describe('Testing /registry/org endpoints', () => { .send(statusOnlyOrg) expect(createRes).to.have.status(200) - expect(createRes.body.created.program_data.status).to.equal('inactive') + expect(createRes.body.created.program_data.status).to.equal('Inactive') expect(createRes.body.created.program_data).to.not.haveOwnProperty('partner_active_date') const statusOnlyUpdateOrg = { ...createRes.body.created } delete statusOnlyUpdateOrg.created @@ -856,7 +862,7 @@ describe('Testing /registry/org endpoints', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.updated).to.haveOwnProperty('program_data') - expect(res.body.updated.program_data.status).to.equal('active') + expect(res.body.updated.program_data.status).to.equal('Active') expect(res.body.updated.program_data).to.not.haveOwnProperty('partner_active_date') }) }) diff --git a/test/unit-tests/org/baseOrgRepositoryTest.js b/test/unit-tests/org/baseOrgRepositoryTest.js index af7b9a632..31cbd747c 100644 --- a/test/unit-tests/org/baseOrgRepositoryTest.js +++ b/test/unit-tests/org/baseOrgRepositoryTest.js @@ -73,7 +73,7 @@ describe('Testing BaseOrgRepository', () => { it('accepts every supported program data status', () => { const repository = new BaseOrgRepository() - for (const status of ['active', 'inactive', 'pending']) { + for (const status of ['Active', 'Inactive', 'Pending']) { const org = { short_name: 'example-org', id_quota: 1, @@ -86,6 +86,23 @@ describe('Testing BaseOrgRepository', () => { } }) + it('normalizes lower-case program data statuses to supported title-case values', () => { + const repository = new BaseOrgRepository() + const expectedStatuses = { active: 'Active', inactive: 'Inactive', pending: 'Pending' } + + for (const [status, expectedStatus] of Object.entries(expectedStatuses)) { + const org = { + short_name: 'example-org', + id_quota: 1, + authority: ['CNA'], + program_data: { status } + } + + expect(repository.validateOrg(org).isValid, status).to.equal(true) + expect(org.program_data.status, status).to.equal(expectedStatus) + expect(new BaseOrgModel(org).validateSync(), status).to.equal(undefined) + } + }) it('rejects an unsupported program data status', () => { const repository = new BaseOrgRepository() const org = { @@ -101,7 +118,7 @@ describe('Testing BaseOrgRepository', () => { instancePath: '/program_data/status', schemaPath: '/BaseOrg#/properties/program_data/properties/status/enum', keyword: 'enum', - params: { allowedValues: ['active', 'inactive', 'pending'] }, + params: { allowedValues: ['Active', 'Inactive', 'Pending'] }, message: 'must be equal to one of the allowed values' }) From 90c36c52cfa3ed714951a044616250012dd4ea34 Mon Sep 17 00:00:00 2001 From: Leo Williamson Date: Wed, 19 Aug 2026 10:29:30 -0400 Subject: [PATCH 3/9] Update registry org status assertion casing --- test/integration-tests/org/registryOrgAsOrgAdmin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration-tests/org/registryOrgAsOrgAdmin.js b/test/integration-tests/org/registryOrgAsOrgAdmin.js index 3ed5f0c25..bf8c01b26 100644 --- a/test/integration-tests/org/registryOrgAsOrgAdmin.js +++ b/test/integration-tests/org/registryOrgAsOrgAdmin.js @@ -290,7 +290,7 @@ describe('Testing Registry Org as org admin', () => { .set(secretariatHeaders) expect(res.body).to.have.property('program_data') - expect(res.body.program_data).to.have.property('status', 'active') + expect(res.body.program_data).to.have.property('status', 'Active') }) }) context('Negative Tests', () => { From 8a4d47f10ff4db7f9ec7c6e2e09cef5fe0feb6fa Mon Sep 17 00:00:00 2001 From: James Dalphond Date: Wed, 9 Sep 2026 11:14:57 -0400 Subject: [PATCH 4/9] Rebasing on dev after 2.8.6 merge --- ...1-normalize-registry-org-program-status.js | 33 +++++++++++++++++++ src/scripts/migrate.js | 2 +- src/scripts/populate.js | 2 +- test/integration-tests/helpers.js | 3 ++ .../registry-org/activeCnaListTest.js | 2 +- .../registryOrgDisabledMigrationTest.js | 9 +++++ 6 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 migrations/20260909-01-normalize-registry-org-program-status.js diff --git a/migrations/20260909-01-normalize-registry-org-program-status.js b/migrations/20260909-01-normalize-registry-org-program-status.js new file mode 100644 index 000000000..ab1eb971d --- /dev/null +++ b/migrations/20260909-01-normalize-registry-org-program-status.js @@ -0,0 +1,33 @@ +const TITLE_CASE_STATUS_BY_LOWER_CASE = { + active: 'Active', + inactive: 'Inactive', + pending: 'Pending' +} + +module.exports = { + async up (db) { + const baseOrgCollection = db.collection('BaseOrg') + + await Promise.all( + Object.entries(TITLE_CASE_STATUS_BY_LOWER_CASE).map(([lowerCaseStatus, titleCaseStatus]) => + baseOrgCollection.updateMany( + { 'program_data.status': lowerCaseStatus }, + { $set: { 'program_data.status': titleCaseStatus } } + ) + ) + ) + }, + + async down (db) { + const baseOrgCollection = db.collection('BaseOrg') + + await Promise.all( + Object.entries(TITLE_CASE_STATUS_BY_LOWER_CASE).map(([lowerCaseStatus, titleCaseStatus]) => + baseOrgCollection.updateMany( + { 'program_data.status': titleCaseStatus }, + { $set: { 'program_data.status': lowerCaseStatus } } + ) + ) + ) + } +} diff --git a/src/scripts/migrate.js b/src/scripts/migrate.js index c643ac7b9..983edd9db 100644 --- a/src/scripts/migrate.js +++ b/src/scripts/migrate.js @@ -214,7 +214,7 @@ async function orgHelper (db) { websites: site ? [site] : [] }, program_data: { - status: 'active' + status: 'Active' }, inUse: doc.inUse, created: doc.time.created, diff --git a/src/scripts/populate.js b/src/scripts/populate.js index b39034332..ce11de10a 100644 --- a/src/scripts/populate.js +++ b/src/scripts/populate.js @@ -157,7 +157,7 @@ function buildBaseOrgDocument (org, allUsers) { id_quota: org.policies?.id_quota, admins: admins, program_data: { - status: 'active' + status: 'Active' }, private_contacts: [], contact_info: { diff --git a/test/integration-tests/helpers.js b/test/integration-tests/helpers.js index de16ccfd8..2286b37d7 100644 --- a/test/integration-tests/helpers.js +++ b/test/integration-tests/helpers.js @@ -13,6 +13,9 @@ async function cveIdReserveHelper (requestLength, year, shortName, batchType) { .post(`/api/cve-id?amount=${requestLength}&cve_year=${year}&short_name=${shortName}&batch_type=${batchType}`) .set(constants.nonSecretariatUserHeaders) .then((res, err) => { + if (res.status !== 200) { + throw new Error(`CVE-ID reservation failed with status ${res.status}: ${JSON.stringify(res.body)}`) + } return res.body.cve_ids[0].cve_id }) } diff --git a/test/integration-tests/registry-org/activeCnaListTest.js b/test/integration-tests/registry-org/activeCnaListTest.js index cdc671729..d6883258c 100644 --- a/test/integration-tests/registry-org/activeCnaListTest.js +++ b/test/integration-tests/registry-org/activeCnaListTest.js @@ -75,7 +75,7 @@ describe('Secretariat active CNA list', () => { const migratedCna = await BaseOrg.findOne({ short_name: 'window_1' }).lean() expect(migratedCna).to.not.equal(null) - expect(migratedCna.program_data.status).to.equal('active') + expect(migratedCna.program_data.status).to.equal('Active') const res = await chai.request(app) .get('/api/registry/org/cnas') diff --git a/test/integration-tests/registry-org/registryOrgDisabledMigrationTest.js b/test/integration-tests/registry-org/registryOrgDisabledMigrationTest.js index 75d9c5c4d..263327be0 100644 --- a/test/integration-tests/registry-org/registryOrgDisabledMigrationTest.js +++ b/test/integration-tests/registry-org/registryOrgDisabledMigrationTest.js @@ -9,6 +9,15 @@ const app = require('../../../src/index.js') const secretariatHeaders = { ...constants.headers, 'content-type': 'application/json' } describe('Registry organization disabled migration', () => { + it('normalizes a seeded registry organization program status', async () => { + const res = await chai.request(app) + .get('/api/registry/org/interesting_19') + .set(secretariatHeaders) + + expect(res).to.have.status(200) + expect(res.body.program_data.status).to.equal('Active') + }) + it('enables a seeded registry organization whose legacy organization has an active authority', async () => { const res = await chai.request(app) .get('/api/registry/org/interesting_19') From cf1e2aa1b0d09d3dd15a378b0f8674643b92febf Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Mon, 21 Sep 2026 16:09:59 -0400 Subject: [PATCH 5/9] First pass at notifications --- api-docs/openapi.json | 106 +++++++++ .../list-notifications-response.json | 6 + schemas/notification/notification.json | 12 + .../notification.controller/index.js | 59 +++++ .../notification.controller.js | 45 ++++ src/model/notification.js | 21 ++ src/repositories/baseOrgRepository.js | 9 + src/repositories/baseUserRepository.js | 5 + src/repositories/conversationRepository.js | 27 +++ src/repositories/notificationRepository.js | 61 +++++ src/repositories/repositoryFactory.js | 5 + src/routes.config.js | 2 + src/swagger.js | 3 +- .../notification/notificationTest.js | 213 ++++++++++++++++++ 14 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 schemas/notification/list-notifications-response.json create mode 100644 schemas/notification/notification.json create mode 100644 src/controller/notification.controller/index.js create mode 100644 src/controller/notification.controller/notification.controller.js create mode 100644 src/model/notification.js create mode 100644 src/repositories/notificationRepository.js create mode 100644 test/integration-tests/notification/notificationTest.js diff --git a/api-docs/openapi.json b/api-docs/openapi.json index e077a9476..029499686 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 9bcc5ecf1..ebbcb7760 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -498,6 +498,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..24a238d15 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -112,6 +112,33 @@ 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) + const authorOrgUUID = await orgRepo.getOrgUUIDByUserUUID(user.UUID, options) + const authorOrg = authorOrgUUID + ? await orgRepo.findOneByUUID(authorOrgUUID, options, false, { short_name: 1 }) + : null + let recipientUUIDs = result.visibility === 'private' || !isSecretariat + ? await orgRepo.findSecretariatUserUUIDs(options) + : targetOrg?.users || (targetUser ? [targetUser.UUID] : []) + if (result.visibility === 'private') { + recipientUUIDs = recipientUUIDs.filter(recipientUUID => recipientUUID !== user.UUID) + } + const activeRecipients = await userRepo.findActiveUsersByUUIDs(recipientUUIDs, options) + await notificationRepo.addNotifications(activeRecipients.map(recipient => recipient.UUID), { + body: result.visibility === 'private' + ? `${user.username} has left a note on the ${targetOrg?.short_name || 'target'} organization.` + : `${result.author_name} has sent a message to ${targetOrg?.short_name || targetUser?.username || 'this organization'}.`, + links: { org_shortname: authorOrg?.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 fd775f5cd..34ac3cba5 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..dccef5ae6 --- /dev/null +++ b/test/integration-tests/notification/notificationTest.js @@ -0,0 +1,213 @@ +/* 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('creates a notification for each active organization user when a public conversation is created', async () => { + const conversationResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ body: 'notification integration test', 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 === 'Secretariat has sent a message to win_5.') + expect(notification).to.exist + expect(notification).to.include({ type: 'PUBLIC_MESSAGE' }) + expect(notification.links).to.deep.equal({ org_shortname: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) + automaticNotificationUUID = notification.UUID + }) + + 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) + expect(notificationsResponse.body.notifications.some(notification => + notification.links.user_shortname === 'test_secretariat_0@mitre.org' && notification.type === 'PUBLIC_MESSAGE' + )).to.equal(true) + }) + + 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) + }) + + 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 = 'test_secretariat_0@mitre.org has left a note on the win_5 organization.' + expect(secretariatResponse.body.notifications.some(item => item.body === privateNotificationBody)).to.equal(false) + expect(otherSecretariatAfter.body.notifications).to.have.lengthOf(otherSecretariatBefore.body.notifications.length + 1) + expect(otherSecretariatAfter.body.notifications.some(item => item.body === privateNotificationBody && item.type === 'PRIVATE_MESSAGE')).to.equal(true) + }) + + it('creates notifications for 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 + delete org.disabled + + const beforeResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(beforeResponse).to.have.status(200) + + const updateResponse = await chai.request(app) + .put('/api/registry/org/win_5') + .set(constants.headers) + .send({ ...org, conversation: { body: 'registry update notification integration test' } }) + 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) + }) + + 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) + }) +}) From 0b8ac2e90e1cef2d3d4f3484d803591f0e36a850 Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Tue, 22 Sep 2026 10:15:51 -0400 Subject: [PATCH 6/9] Fixing missing stubs for unit tests --- .../conversation/conversationRepositoryTest.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/unit-tests/conversation/conversationRepositoryTest.js b/test/unit-tests/conversation/conversationRepositoryTest.js index d8cab9b19..f8056f06b 100644 --- a/test/unit-tests/conversation/conversationRepositoryTest.js +++ b/test/unit-tests/conversation/conversationRepositoryTest.js @@ -5,8 +5,22 @@ 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 author-name tests do not require a database connection. + sinon.stub(BaseOrgRepository.prototype, 'findOneByUUID').resolves(null) + sinon.stub(BaseOrgRepository.prototype, 'getOrgUUIDByUserUUID').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() }) From bfff0cc07435175e60d1ae55fd1b73afdf9ca877 Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Wed, 23 Sep 2026 11:25:31 -0400 Subject: [PATCH 7/9] Added the body stuff --- src/repositories/conversationRepository.js | 4 +--- .../notification/notificationTest.js | 22 ++++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index 24a238d15..7115f214b 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -132,9 +132,7 @@ class ConversationRepository extends BaseRepository { } const activeRecipients = await userRepo.findActiveUsersByUUIDs(recipientUUIDs, options) await notificationRepo.addNotifications(activeRecipients.map(recipient => recipient.UUID), { - body: result.visibility === 'private' - ? `${user.username} has left a note on the ${targetOrg?.short_name || 'target'} organization.` - : `${result.author_name} has sent a message to ${targetOrg?.short_name || targetUser?.username || 'this organization'}.`, + body: result.body, links: { org_shortname: authorOrg?.short_name || '', user_shortname: user.username || '' }, type: result.visibility === 'private' ? 'PRIVATE_MESSAGE' : 'PUBLIC_MESSAGE' }, options) diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js index dccef5ae6..9417f05f2 100644 --- a/test/integration-tests/notification/notificationTest.js +++ b/test/integration-tests/notification/notificationTest.js @@ -36,10 +36,11 @@ describe('Testing Notification endpoints', () => { }) it('creates a notification for each active organization user when a public conversation is created', 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: 'notification integration test', visibility: 'public' }) + .send({ body: messageBody, visibility: 'public' }) expect(conversationResponse).to.have.status(200) conversationUUID = conversationResponse.body.UUID @@ -49,7 +50,7 @@ describe('Testing Notification endpoints', () => { 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 === 'Secretariat has sent a message to win_5.') + 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: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) @@ -79,9 +80,10 @@ describe('Testing Notification endpoints', () => { .get('/api/notification') .set(constants.nonSecretariatUserHeaders2) expect(notificationsResponse).to.have.status(200) - expect(notificationsResponse.body.notifications.some(notification => - notification.links.user_shortname === 'test_secretariat_0@mitre.org' && notification.type === 'PUBLIC_MESSAGE' - )).to.equal(true) + 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: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) }) it('notifies Secretariat users, rather than CNA members, about CNA-authored conversations', async () => { @@ -102,6 +104,10 @@ describe('Testing Notification endpoints', () => { 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('does not create a notification when an existing conversation is edited', async () => { @@ -139,8 +145,9 @@ describe('Testing Notification endpoints', () => { 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 = 'test_secretariat_0@mitre.org has left a note on the win_5 organization.' + 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) expect(otherSecretariatAfter.body.notifications.some(item => item.body === privateNotificationBody && item.type === 'PRIVATE_MESSAGE')).to.equal(true) }) @@ -172,6 +179,9 @@ describe('Testing Notification endpoints', () => { 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) + expect(afterResponse.body.notifications.some(item => + item.body === 'registry update notification integration test' && item.type === 'PRIVATE_MESSAGE' + )).to.equal(true) }) it('allows Secretariat to create a generic notification for an existing user', async () => { From f637978f45ad5db68a06f10fca8d39b7dce3508c Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Wed, 23 Sep 2026 16:18:22 -0400 Subject: [PATCH 8/9] Iterating on feedback --- src/repositories/conversationRepository.js | 16 +- .../notification/notificationTest.js | 151 ++++++++++++++---- .../conversationRepositoryTest.js | 46 +++++- 3 files changed, 167 insertions(+), 46 deletions(-) diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index 7115f214b..4e245477d 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -120,20 +120,18 @@ class ConversationRepository extends BaseRepository { 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) - const authorOrgUUID = await orgRepo.getOrgUUIDByUserUUID(user.UUID, options) - const authorOrg = authorOrgUUID - ? await orgRepo.findOneByUUID(authorOrgUUID, options, false, { short_name: 1 }) - : null - let recipientUUIDs = result.visibility === 'private' || !isSecretariat - ? await orgRepo.findSecretariatUserUUIDs(options) - : targetOrg?.users || (targetUser ? [targetUser.UUID] : []) - if (result.visibility === 'private') { + 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: authorOrg?.short_name || '', user_shortname: user.username || '' }, + links: { org_shortname: targetOrg?.short_name || '', user_shortname: user.username || '' }, type: result.visibility === 'private' ? 'PRIVATE_MESSAGE' : 'PUBLIC_MESSAGE' }, options) diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js index 9417f05f2..a3f1bffa3 100644 --- a/test/integration-tests/notification/notificationTest.js +++ b/test/integration-tests/notification/notificationTest.js @@ -35,7 +35,7 @@ describe('Testing Notification endpoints', () => { await NotificationModel.deleteMany({ user_uuid: recipientUUID }) }) - it('creates a notification for each active organization user when a public conversation is created', async () => { + 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}`) @@ -53,8 +53,18 @@ describe('Testing Notification endpoints', () => { 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: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) + 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 () => { @@ -83,7 +93,14 @@ describe('Testing Notification endpoints', () => { 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: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) + 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 () => { @@ -110,6 +127,54 @@ describe('Testing Notification endpoints', () => { 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) @@ -149,40 +214,56 @@ describe('Testing Notification endpoints', () => { 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) - expect(otherSecretariatAfter.body.notifications.some(item => item.body === privateNotificationBody && item.type === 'PRIVATE_MESSAGE')).to.equal(true) + 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' }) }) - it('creates notifications for 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 - delete org.disabled - - const beforeResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) - expect(beforeResponse).to.have.status(200) - - const updateResponse = await chai.request(app) - .put('/api/registry/org/win_5') - .set(constants.headers) - .send({ ...org, conversation: { body: 'registry update notification integration test' } }) - 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) - expect(afterResponse.body.notifications.some(item => - item.body === 'registry update notification integration test' && item.type === 'PRIVATE_MESSAGE' - )).to.equal(true) - }) + 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 + delete org.disabled + + 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) diff --git a/test/unit-tests/conversation/conversationRepositoryTest.js b/test/unit-tests/conversation/conversationRepositoryTest.js index f8056f06b..915305451 100644 --- a/test/unit-tests/conversation/conversationRepositoryTest.js +++ b/test/unit-tests/conversation/conversationRepositoryTest.js @@ -12,9 +12,8 @@ const NotificationRepository = require('../../../src/repositories/notificationRe describe('Testing Conversation Repository', () => { beforeEach(() => { // Conversation creation also resolves notification recipients and writes notifications. - // Stub those dependencies so author-name tests do not require a database connection. + // Stub those dependencies so repository tests do not require a database connection. sinon.stub(BaseOrgRepository.prototype, 'findOneByUUID').resolves(null) - sinon.stub(BaseOrgRepository.prototype, 'getOrgUUIDByUserUUID').resolves(null) sinon.stub(BaseOrgRepository.prototype, 'findSecretariatUserUUIDs').resolves([]) sinon.stub(BaseUserRepository.prototype, 'findUserByUUID').resolves(null) sinon.stub(BaseUserRepository.prototype, 'findActiveUsersByUUIDs').resolves([]) @@ -73,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([ From 78414a27994e246f382b8cb7b9997d7956c4a043 Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Mon, 28 Sep 2026 10:10:29 -0400 Subject: [PATCH 9/9] Notification unit test changes --- test/integration-tests/notification/notificationTest.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js index a3f1bffa3..80158c8fb 100644 --- a/test/integration-tests/notification/notificationTest.js +++ b/test/integration-tests/notification/notificationTest.js @@ -234,7 +234,6 @@ describe('Testing Notification endpoints', () => { delete org.reports_to delete org._hierarchy delete org.program_data - delete org.disabled const beforeResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) expect(beforeResponse).to.have.status(200)