From bb1f0bbc215aaf6e37f769c40939d8a8a2982d4e Mon Sep 17 00:00:00 2001 From: david-rocca Date: Wed, 24 Jun 2026 16:03:27 -0400 Subject: [PATCH 01/19] Fixing hung sessions --- .../audit.controller/audit.controller.js | 39 ++-- .../conversation.controller.js | 4 + .../org.controller/org.controller.js | 11 +- .../registry-org.controller.js | 10 +- .../registry-user.controller.js | 26 ++- .../review-object.controller.js | 33 +-- .../controllerSessionCleanupTest.js | 211 ++++++++++++++++++ 7 files changed, 285 insertions(+), 49 deletions(-) create mode 100644 test/unit-tests/controllerSessionCleanupTest.js diff --git a/src/controller/audit.controller/audit.controller.js b/src/controller/audit.controller/audit.controller.js index 467b698f1..205bd5c46 100644 --- a/src/controller/audit.controller/audit.controller.js +++ b/src/controller/audit.controller/audit.controller.js @@ -10,9 +10,6 @@ const validateUUID = require('uuid').validate */ async function createAuditDocumentForOrg (req, res, next) { try { - const session = await mongoose.startSession() - const repo = req.ctx.repositories.getAuditRepository() - const orgRepo = req.ctx.repositories.getBaseOrgRepository() const body = req.ctx.body let returnValue @@ -30,6 +27,10 @@ async function createAuditDocumentForOrg (req, res, next) { return res.status(400).json(error.invalidUUID('target_uuid')) } + const repo = req.ctx.repositories.getAuditRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const session = await mongoose.startSession() + try { session.startTransaction() @@ -117,9 +118,6 @@ async function createAuditDocumentForOrg (req, res, next) { */ async function appendToAuditHistoryForOrg (req, res, next) { try { - const session = await mongoose.startSession() - const repo = req.ctx.repositories.getAuditRepository() - const orgRepo = req.ctx.repositories.getBaseOrgRepository() const body = req.ctx.body let returnValue @@ -135,6 +133,10 @@ async function appendToAuditHistoryForOrg (req, res, next) { return res.status(400).json(error.invalidUUID('target_uuid')) } + const repo = req.ctx.repositories.getAuditRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const session = await mongoose.startSession() + try { session.startTransaction() @@ -207,8 +209,8 @@ async function appendToAuditHistoryForOrg (req, res, next) { */ async function getAllOrgAuditDocuments (req, res, next) { try { - const session = await mongoose.startSession() const repo = req.ctx.repositories.getAuditRepository() + const session = await mongoose.startSession() let returnValue try { @@ -230,8 +232,6 @@ async function getAllOrgAuditDocuments (req, res, next) { */ async function getOrgAuditByDocumentUUID (req, res, next) { try { - const session = await mongoose.startSession() - const repo = req.ctx.repositories.getAuditRepository() const documentUUID = req.ctx.params.document_uuid let returnValue @@ -245,6 +245,9 @@ async function getOrgAuditByDocumentUUID (req, res, next) { return res.status(400).json(error.invalidUUID('document_uuid')) } + const session = await mongoose.startSession() + const repo = req.ctx.repositories.getAuditRepository() + try { returnValue = await repo.findOneByUUID(documentUUID, { session }) @@ -269,17 +272,18 @@ async function getOrgAuditByDocumentUUID (req, res, next) { */ async function getOrgAuditByOrgIdentifier (req, res, next) { try { - const session = await mongoose.startSession() - const repo = req.ctx.repositories.getAuditRepository() - const orgRepo = req.ctx.repositories.getBaseOrgRepository() const identifier = req.ctx.params.org_identifier - const identifierIsUUID = validateUUID(identifier) let returnValue if (!identifier) { return res.status(400).json(error.missingRequiredField('identifier')) } + const identifierIsUUID = validateUUID(identifier) + const repo = req.ctx.repositories.getAuditRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const session = await mongoose.startSession() + try { session.startTransaction() @@ -335,11 +339,7 @@ async function getOrgAuditByOrgIdentifier (req, res, next) { */ async function getLastXChanges (req, res, next) { try { - const session = await mongoose.startSession() - const repo = req.ctx.repositories.getAuditRepository() - const orgRepo = req.ctx.repositories.getBaseOrgRepository() const identifier = req.ctx.params.org_identifier - const identifierIsUUID = validateUUID(identifier) const numberOfChanges = parseInt(req.ctx.params.number_of_changes) let returnValue @@ -352,6 +352,11 @@ async function getLastXChanges (req, res, next) { return res.status(400).json(error.invalidNumberOfChanges()) } + const identifierIsUUID = validateUUID(identifier) + const repo = req.ctx.repositories.getAuditRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const session = await mongoose.startSession() + try { session.startTransaction() diff --git a/src/controller/conversation.controller/conversation.controller.js b/src/controller/conversation.controller/conversation.controller.js index e79d60227..71bf8fa1d 100644 --- a/src/controller/conversation.controller/conversation.controller.js +++ b/src/controller/conversation.controller/conversation.controller.js @@ -45,6 +45,7 @@ async function createConversationForTargetUUID (req, res, next) { const user = await authContext.getRequesterUser(req, userRepo, orgRepo, { session }) if (typeof body !== 'object' || !body.body || !repo.validateConversation(body)) { + await session.abortTransaction() return res.status(400).json(error.invalidConversationObject()) } @@ -53,6 +54,7 @@ async function createConversationForTargetUUID (req, res, next) { if (!isSecretariat) { const orgUUID = await authContext.getRequesterOrgUUID(req, orgRepo, { session }) if (targetUUID !== orgUUID) { + await session.abortTransaction() return res.status(403).json({ error: 'UNAUTHORIZED', message: 'Unauthorized' }) } } @@ -98,12 +100,14 @@ async function updateConversationByUUID (req, res, next) { const conversation = await repo.findOneByUUID(conversationUUID, { session }) if (!conversation) { logger.info({ uuid: req.ctx.uuid, message: `No conversation found with UUID ${conversationUUID}` }) + await session.abortTransaction() return res.status(404).json(error.conversationDne(conversationUUID)) } // Validate body if (typeof body !== 'object' || !(body.body || body.visibility) || !repo.validateConversation(body)) { logger.info({ uuid: req.ctx.uuid, message: 'The conversation could not be edited because the request body was invalid.' }) + await session.abortTransaction() return res.status(400).json(error.invalidConversationEditObject()) } diff --git a/src/controller/org.controller/org.controller.js b/src/controller/org.controller/org.controller.js index 035439b1d..ac49001d7 100644 --- a/src/controller/org.controller/org.controller.js +++ b/src/controller/org.controller/org.controller.js @@ -253,13 +253,14 @@ async function getOrgIdQuota (req, res, next) { */ async function createOrg (req, res, next) { try { - const session = await mongoose.startSession() const repo = req.ctx.repositories.getBaseOrgRepository() const body = req.ctx.body let returnValue // Do not allow the user to pass in a UUID if ((body?.UUID ?? null) || (body?.uuid ?? null)) return res.status(400).json(error.uuidProvided('org')) + const session = await mongoose.startSession() + try { session.startTransaction() @@ -369,10 +370,12 @@ async function updateOrg (req, res, next) { if (!(await orgRepository.orgExists(shortNameUrlParameter, { session }))) { logger.info({ uuid: req.ctx.uuid, message: `Organization ${shortNameUrlParameter} not found.` }) + await session.abortTransaction() return res.status(404).json(error.orgDnePathParam(shortNameUrlParameter)) } if (Object.hasOwn(queryParametersJson, 'new_short_name') && (await orgRepository.orgExists(queryParametersJson.new_short_name, { session }))) { + await session.abortTransaction() return res.status(403).json(error.duplicateShortname(queryParametersJson.new_short_name)) } @@ -438,7 +441,6 @@ async function updateOrg (req, res, next) { * @returns {Promise} */ async function createUser (req, res, next) { - const session = await mongoose.startSession() try { const body = req.ctx.body const userRepo = req.ctx.repositories.getBaseUserRepository() @@ -463,14 +465,18 @@ async function createUser (req, res, next) { return res.status(400).json(error.uuidProvided('org')) } + const session = await mongoose.startSession() + try { session.startTransaction() if (req.useRegistry) { const result = await userRepo.validateUser(body) if (body?.role && typeof body?.role !== 'string') { + await session.abortTransaction() return res.status(400).json({ message: 'Parameters were invalid', details: [{ param: 'role', msg: 'Parameter must be a string' }] }) } if (body?.role && !constants.USER_ROLES.includes(body?.role)) { + await session.abortTransaction() return res.status(400).json({ message: 'Parameters were invalid', details: [{ param: 'role', msg: `Role must be one of the following: ${constants.USER_ROLES}` }] }) } if (!result.isValid) { @@ -480,6 +486,7 @@ async function createUser (req, res, next) { } } else { if (!body?.username || typeof body?.username !== 'string') { + await session.abortTransaction() return res.status(400).json({ message: 'Parameters were invalid', details: [{ param: 'username', msg: 'Parameter must be a non empty string' }] }) } } diff --git a/src/controller/registry-org.controller/registry-org.controller.js b/src/controller/registry-org.controller/registry-org.controller.js index ad66e2c25..91a1c55f4 100644 --- a/src/controller/registry-org.controller/registry-org.controller.js +++ b/src/controller/registry-org.controller/registry-org.controller.js @@ -247,10 +247,8 @@ async function getOrg (req, res, next) { */ async function createOrg (req, res, next) { try { - const session = await mongoose.startSession({ causalConsistency: false }) const repo = req.ctx.repositories.getBaseOrgRepository() const body = req.ctx.body - const isSecretariat = await authContext.isRequesterSecretariat(req, repo, { session }) let createdOrg // Do not allow the user to pass in a UUID @@ -258,6 +256,8 @@ async function createOrg (req, res, next) { return res.status(400).json(error.uuidProvided('org')) } + const isSecretariat = await authContext.isRequesterSecretariat(req, repo) + if (!isSecretariat) { const secretariatOnlyFields = getConstants().SECRETARIAT_ONLY_FIELDS const restrictedFieldsSent = secretariatOnlyFields.filter(field => _.has(body, field)) @@ -267,6 +267,8 @@ async function createOrg (req, res, next) { } } + const session = await mongoose.startSession({ causalConsistency: false }) + try { session.startTransaction() const result = repo.validateOrg(body, { session }) @@ -730,7 +732,6 @@ async function getUsers (req, res, next) { * Called by POST /api/registryOrg/:shortname/user */ async function createUserByOrg (req, res, next) { - const session = await mongoose.startSession({ causalConsistency: false }) try { const body = req.ctx.body const userRepo = req.ctx.repositories.getBaseUserRepository() @@ -754,10 +755,13 @@ async function createUserByOrg (req, res, next) { return res.status(400).json(error.uuidProvided('org')) } + const session = await mongoose.startSession({ causalConsistency: false }) + try { session.startTransaction() const result = await userRepo.validateUser(body) if (body?.role && typeof body?.role !== 'string') { + await session.abortTransaction() return res.status(400).json({ message: 'Parameters were invalid', details: [{ param: 'role', msg: 'Parameter must be a string' }] }) } if (!result.isValid) { diff --git a/src/controller/registry-user.controller/registry-user.controller.js b/src/controller/registry-user.controller/registry-user.controller.js index c1d01a1ca..51fb8ed0d 100644 --- a/src/controller/registry-user.controller/registry-user.controller.js +++ b/src/controller/registry-user.controller/registry-user.controller.js @@ -166,7 +166,6 @@ async function getUser (req, res, next) { } async function createUser (req, res, next) { - const session = await mongoose.startSession({ causalConsistency: false }) try { const orgRepo = req.ctx.repositories.getBaseOrgRepository() const userRepo = req.ctx.repositories.getBaseUserRepository() @@ -189,11 +188,14 @@ async function createUser (req, res, next) { return res.status(400).json(error.uuidProvided('org')) } + const session = await mongoose.startSession({ causalConsistency: false }) + try { session.startTransaction() const result = await userRepo.validateUser(body) if (body?.role && typeof body?.role !== 'string') { + await session.abortTransaction() return res.status(400).json({ message: 'Parameters were invalid', details: [{ param: 'role', msg: 'Parameter must be a string' }] }) } if (!result.isValid) { @@ -243,7 +245,6 @@ async function updateUser (req, res, next) { We need to make sure that either way we convert to one or the other. For now, I am going shortname / username */ - const session = await mongoose.startSession({ causalConsistency: false }) // Check to see if identifier is set const identifier = req.ctx.params.identifier @@ -275,7 +276,7 @@ async function updateUser (req, res, next) { username: req.ctx.params.username } - const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, { session }) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) // TODO: This will need to be atomic at some point like revoke or grant // Specific check for org_short_name (Secretariat only) @@ -283,7 +284,7 @@ async function updateUser (req, res, next) { let userToEdit let org if (identifier) { - userToEdit = await userRepo.findUserByUUID(identifier, { session }) + userToEdit = await userRepo.findUserByUUID(identifier) if (!userToEdit) { logger.info({ uuid: req.ctx.uuid, message: identifier + ' user could not be found.' }) return res.status(404).json(error.userDne(identifier)) @@ -295,11 +296,11 @@ async function updateUser (req, res, next) { return res.status(404).json(error.orgDnePathParam(identifier)) } - org = await orgRepo.findOneByUUID(orgUUID, { session }) + org = await orgRepo.findOneByUUID(orgUUID) userToEditParameters.org = org.short_name userToEditParameters.username = userToEdit.username } else { - userToEdit = await userRepo.findOneByUsernameAndOrgShortname(userToEditParameters.username, userToEditParameters.org, { session }) + userToEdit = await userRepo.findOneByUsernameAndOrgShortname(userToEditParameters.username, userToEditParameters.org) org = await orgRepo.findOneByShortName(userToEditParameters.org) if (!org) { logger.info({ uuid: req.ctx.uuid, message: `Target organization ${userToEditParameters.org} does not exist.` }) @@ -307,8 +308,8 @@ async function updateUser (req, res, next) { } } - const isAdmin = await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, org, { session }) - const requesterUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) + const isAdmin = await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, org) + const requesterUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) // Allow existing UUIDs to be passed, but block any attempts to mutate them if (userToEdit) { @@ -346,7 +347,7 @@ async function updateUser (req, res, next) { return res.status(404).json(error.orgDnePathParam(userToEditParameters.org)) } - const requesterSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, org, { session }) + const requesterSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, org) if (!isSecretariat && !isAdmin && !requesterSameOrg) { logger.info({ uuid: req.ctx.uuid, message: requestingUserParameters.org + ' user can only be updated by the user or admins of the same organization or the Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) @@ -375,6 +376,7 @@ async function updateUser (req, res, next) { let result let updatedUser let updatedUserUUID + const session = await mongoose.startSession({ causalConsistency: false }) try { session.startTransaction() try { @@ -480,7 +482,6 @@ async function deleteUser (req, res, next) { async function grantRole (req, res, next) { // Explicitly configuring causalConsistency flag for clear DocumentDB context documentation - const session = await mongoose.startSession({ causalConsistency: false }) try { const orgShortName = req.ctx.params.shortname const username = req.ctx.params.username @@ -522,6 +523,8 @@ async function grantRole (req, res, next) { return res.status(403).json(error.notOrgAdminOrSecretariatUpdate()) } + const session = await mongoose.startSession({ causalConsistency: false }) + try { session.startTransaction() const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) @@ -543,7 +546,6 @@ async function grantRole (req, res, next) { async function revokeRole (req, res, next) { // Explicitly configuring causalConsistency flag for clear DocumentDB context documentation - const session = await mongoose.startSession({ causalConsistency: false }) try { const orgShortName = req.ctx.params.shortname const username = req.ctx.params.username @@ -591,6 +593,8 @@ async function revokeRole (req, res, next) { return res.status(403).json({ error: 'NOT_ALLOWED_TO_SELF_DEMOTE', message: 'You cannot remove the ADMIN role from yourself.' }) } + const session = await mongoose.startSession({ causalConsistency: false }) + try { session.startTransaction() const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) diff --git a/src/controller/review-object.controller/review-object.controller.js b/src/controller/review-object.controller/review-object.controller.js index 0a57fb37c..5e105d294 100644 --- a/src/controller/review-object.controller/review-object.controller.js +++ b/src/controller/review-object.controller/review-object.controller.js @@ -88,18 +88,18 @@ async function approveReviewObject (req, res, next) { const isPendingReview = true const UUID = req.params.uuid const body = req.body - const session = await mongoose.startSession({ causalConsistency: false }) let updatedOrgObj + const bodyValidation = (body && Object.keys(body).length) ? baseOrgRepo.validateOrg(body) : { isValid: true } + if (!bodyValidation.isValid) { + return res.status(400).json({ message: 'Invalid body parameters', errors: bodyValidation.errors }) + } + + const session = await mongoose.startSession({ causalConsistency: false }) + try { session.startTransaction() - const bodyValidation = (body && Object.keys(body).length) ? baseOrgRepo.validateOrg(body) : { isValid: true } - if (!bodyValidation.isValid) { - await session.abortTransaction() - return res.status(400).json({ message: 'Invalid body parameters', errors: bodyValidation.errors }) - } - const reviewObject = await reviewRepo.findOneByUUIDWithConversation(UUID, isSecretariat, isPendingReview, { session }) if (!reviewObject) { await session.abortTransaction() @@ -145,7 +145,6 @@ async function updateReviewObjectByReviewUUID (req, res, next) { const UUID = req.params.uuid const orgRepo = req.ctx.repositories.getBaseOrgRepository() const body = req.body - const session = await mongoose.startSession({ causalConsistency: false }) let updatedReviewObj const result = orgRepo.validateOrg(body) @@ -153,6 +152,8 @@ async function updateReviewObjectByReviewUUID (req, res, next) { return res.status(400).json({ message: 'Invalid new_review_data', errors: result.errors }) } + const session = await mongoose.startSession({ causalConsistency: false }) + try { session.startTransaction() const reviewObject = await repo.findOneByUUIDWithConversation(UUID, false, true, { session }) @@ -179,16 +180,17 @@ async function createReviewObject (req, res, next) { const baseOrgRepo = req.ctx.repositories.getBaseOrgRepository() const repo = req.ctx.repositories.getReviewObjectRepository() const body = req.body - const session = await mongoose.startSession({ causalConsistency: false }) let createdReviewObj + const bodyValidation = (body && Object.keys(body).length) ? baseOrgRepo.validateOrg(body) : { isValid: false } + if (!bodyValidation.isValid) { + return res.status(400).json({ message: 'Invalid body parameters', errors: bodyValidation.errors }) + } + + const session = await mongoose.startSession({ causalConsistency: false }) + try { session.startTransaction() - const bodyValidation = (body && Object.keys(body).length) ? baseOrgRepo.validateOrg(body, { session }) : { isValid: false } - if (!bodyValidation.isValid) { - await session.abortTransaction() - return res.status(400).json({ message: 'Invalid body parameters', errors: bodyValidation.errors }) - } createdReviewObj = await repo.createReviewOrgObject(body, req.ctx.user, { session }) await session.commitTransaction() } catch (createErr) { @@ -246,13 +248,12 @@ async function rejectReviewObject (req, res, next) { const UUID = req.params.uuid const session = await mongoose.startSession({ causalConsistency: false }) - const isSecretariat = await authContext.isRequesterSecretariat(req, baseOrgRepo, { session }) - const isPendingReview = true let value try { session.startTransaction() + const isSecretariat = await authContext.isRequesterSecretariat(req, baseOrgRepo, { session }) const reviewObject = await reviewRepo.findOneByUUIDWithConversation(UUID, isSecretariat, isPendingReview, { session }) if (!reviewObject) { diff --git a/test/unit-tests/controllerSessionCleanupTest.js b/test/unit-tests/controllerSessionCleanupTest.js new file mode 100644 index 000000000..ce94405c6 --- /dev/null +++ b/test/unit-tests/controllerSessionCleanupTest.js @@ -0,0 +1,211 @@ +/* global describe, afterEach, it */ + +const { expect } = require('chai') +const sinon = require('sinon') +const mongoose = require('mongoose') + +const auditController = require('../../src/controller/audit.controller/audit.controller') +const registryUserController = require('../../src/controller/registry-user.controller/registry-user.controller') +const reviewObjectController = require('../../src/controller/review-object.controller/review-object.controller') +const orgController = require('../../src/controller/org.controller/org.controller') +const registryOrgController = require('../../src/controller/registry-org.controller/registry-org.controller') +const conversationController = require('../../src/controller/conversation.controller/conversation.controller') +const authContext = require('../../src/utils/authContext') + +function mockResponse () { + const res = {} + res.status = sinon.stub().returns(res) + res.json = sinon.stub().returns(res) + return res +} + +function mockSession () { + return { + id: { id: 'session-id' }, + startTransaction: sinon.stub(), + abortTransaction: sinon.stub().resolves(), + commitTransaction: sinon.stub().resolves(), + endSession: sinon.stub().resolves(), + inTransaction: sinon.stub().returns(true) + } +} + +describe('Controller Mongoose session cleanup', () => { + afterEach(() => { + sinon.restore() + }) + + it('does not create an audit session when create validation fails', async () => { + sinon.stub(mongoose, 'startSession').resolves(mockSession()) + const res = mockResponse() + const req = { + ctx: { + uuid: 'request-uuid', + body: { target_uuid: 'not-a-uuid' } + } + } + + await auditController.AUDIT_CREATE_SINGLE(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(mongoose.startSession.notCalled).to.equal(true) + }) + + it('does not create a registry-user session when the target org is missing', async () => { + sinon.stub(mongoose, 'startSession').resolves(mockSession()) + const res = mockResponse() + const req = { + ctx: { + uuid: 'request-uuid', + body: { username: 'created_user@example.org' }, + params: { shortname: 'missing_org' }, + repositories: { + getBaseOrgRepository: () => ({ getOrgUUID: sinon.stub().resolves(null) }), + getBaseUserRepository: () => ({}) + } + } + } + + await registryUserController.CREATE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(404)).to.equal(true) + expect(mongoose.startSession.notCalled).to.equal(true) + }) + + it('aborts and ends a registry-user create transaction when role validation fails', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + const res = mockResponse() + const req = { + ctx: { + uuid: 'request-uuid', + body: { username: 'created_user@example.org', role: 42 }, + params: { shortname: 'range_4' }, + repositories: { + getBaseOrgRepository: () => ({ getOrgUUID: sinon.stub().resolves('org-uuid') }), + getBaseUserRepository: () => ({ validateUser: sinon.stub().resolves({ isValid: true }) }) + } + } + } + + await registryUserController.CREATE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(session.abortTransaction.calledOnce).to.equal(true) + expect(session.endSession.calledOnce).to.equal(true) + }) + + it('does not create a review-object session when update validation fails', async () => { + sinon.stub(mongoose, 'startSession').resolves(mockSession()) + const res = mockResponse() + const req = { + params: { uuid: 'review-uuid' }, + body: { short_name: 'bad' }, + ctx: { + repositories: { + getReviewObjectRepository: () => ({}), + getBaseOrgRepository: () => ({ + validateOrg: sinon.stub().returns({ isValid: false, errors: [{ message: 'invalid' }] }) + }) + } + } + } + + await reviewObjectController.updateReviewObjectByReviewUUID(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(mongoose.startSession.notCalled).to.equal(true) + }) + + it('aborts and ends an org update transaction when the target org is missing', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + const res = mockResponse() + const req = { + ctx: { + uuid: 'request-uuid', + params: { shortname: 'missing_org' }, + query: {}, + repositories: { + getBaseOrgRepository: () => ({ orgExists: sinon.stub().resolves(false) }), + getBaseUserRepository: () => ({}) + } + } + } + + await orgController.ORG_UPDATE_SINGLE(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(404)).to.equal(true) + expect(session.abortTransaction.calledOnce).to.equal(true) + expect(session.endSession.calledOnce).to.equal(true) + }) + + it('does not create a registry-org user session when the target org is missing', async () => { + sinon.stub(mongoose, 'startSession').resolves(mockSession()) + const res = mockResponse() + const req = { + ctx: { + uuid: 'request-uuid', + body: { username: 'created_user@example.org' }, + params: { shortname: 'missing_org' }, + repositories: { + getBaseOrgRepository: () => ({ getOrgUUID: sinon.stub().resolves(null) }), + getBaseUserRepository: () => ({}) + } + } + } + + await registryOrgController.USER_CREATE_SINGLE(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(404)).to.equal(true) + expect(mongoose.startSession.notCalled).to.equal(true) + }) + + it('aborts and ends a registry-org user create transaction when role validation fails', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + const res = mockResponse() + const req = { + ctx: { + uuid: 'request-uuid', + body: { username: 'created_user@example.org', role: 42 }, + params: { shortname: 'range_4' }, + repositories: { + getBaseOrgRepository: () => ({ getOrgUUID: sinon.stub().resolves('org-uuid') }), + getBaseUserRepository: () => ({ validateUser: sinon.stub().resolves({ isValid: true }) }) + } + } + } + + await registryOrgController.USER_CREATE_SINGLE(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(session.abortTransaction.calledOnce).to.equal(true) + expect(session.endSession.calledOnce).to.equal(true) + }) + + it('aborts and ends a conversation create transaction when body validation fails', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + sinon.stub(authContext, 'getRequesterUser').resolves({ UUID: 'user-uuid' }) + const res = mockResponse() + const req = { + params: { uuid: 'target-uuid' }, + body: {}, + ctx: { + uuid: 'request-uuid', + repositories: { + getConversationRepository: () => ({ validateConversation: sinon.stub().returns(false) }), + getBaseUserRepository: () => ({}), + getBaseOrgRepository: () => ({}) + } + } + } + + await conversationController.createConversationForTargetUUID(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(session.abortTransaction.calledOnce).to.equal(true) + expect(session.endSession.calledOnce).to.equal(true) + }) +}) From 2bf10f14d6597c8f600b852dc21a3dded1a71d1c Mon Sep 17 00:00:00 2001 From: david-rocca Date: Thu, 25 Jun 2026 14:05:31 -0400 Subject: [PATCH 02/19] oversized json fix --- src/middleware/middleware.js | 2 +- .../middleware/jsonSyntaxTest.js | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 test/integration-tests/middleware/jsonSyntaxTest.js diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index 6a9206b6b..1611594e9 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -392,7 +392,7 @@ function validateJsonSyntax (err, req, res, next) { if (err.message.includes('request entity too large')) { console.warn('Request failed validation because entity too large') console.info((JSON.stringify(err))) - return res.status(413).json(error.recordTooLarge(errors)) + return res.status(413).json(error.recordTooLarge()) } else if (err.status === 400) { console.warn('Request failed validation because JSON syntax is incorrect') console.info((JSON.stringify(err))) diff --git a/test/integration-tests/middleware/jsonSyntaxTest.js b/test/integration-tests/middleware/jsonSyntaxTest.js new file mode 100644 index 000000000..057402e82 --- /dev/null +++ b/test/integration-tests/middleware/jsonSyntaxTest.js @@ -0,0 +1,48 @@ +const express = require('express') +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-http')) + +const middleware = require('../../../src/middleware/middleware') + +function createJsonSyntaxTestApp () { + const app = express() + + app.use(express.json({ limit: '1kb', inflate: false })) + app.post('/json', (req, res) => { + return res.status(200).json({ message: 'Success' }) + }) + app.use(middleware.validateJsonSyntax) + + return app +} + +describe('JSON syntax middleware integration', () => { + it('returns 413 for oversized JSON requests', async () => { + const app = createJsonSyntaxTestApp() + const payload = { value: 'x'.repeat(2048) } + + const res = await chai.request(app) + .post('/json') + .set('content-type', 'application/json') + .send(payload) + + expect(res).to.have.status(413) + expect(res.body).to.deep.equal({ + error: 'RECORD_TOO_LARGE', + message: 'Records must be less than 3.8MB.' + }) + }) + + it('keeps malformed JSON requests on the invalid syntax response path', async () => { + const app = createJsonSyntaxTestApp() + + const res = await chai.request(app) + .post('/json') + .set('content-type', 'application/json') + .send('{"value":') + + expect(res).to.have.status(400) + expect(res.body.error).to.equal('INVALID_JSON_SYNTAX') + }) +}) From 40489f5ab732bed2099751e435c0303988c53bae Mon Sep 17 00:00:00 2001 From: david-rocca Date: Thu, 25 Jun 2026 14:43:39 -0400 Subject: [PATCH 03/19] Fixing the state of conversation middleware --- .../conversation.controller.js | 1 + .../conversation.middleware.js | 37 ++++++ .../conversation.controller/index.js | 9 ++ .../conversation/conversationTest.js | 106 +++++++++++++++++- 4 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/controller/conversation.controller/conversation.middleware.js diff --git a/src/controller/conversation.controller/conversation.controller.js b/src/controller/conversation.controller/conversation.controller.js index 71bf8fa1d..b188dc895 100644 --- a/src/controller/conversation.controller/conversation.controller.js +++ b/src/controller/conversation.controller/conversation.controller.js @@ -17,6 +17,7 @@ async function getAllConversations (req, res, next) { const options = CONSTANTS.PAGINATOR_OPTIONS options.sort = { posted_at: 'desc' } + options.page = req.ctx.query.page ? parseInt(req.ctx.query.page) : CONSTANTS.PAGINATOR_PAGE const response = await repo.getAll(options) return res.status(200).json(response) diff --git a/src/controller/conversation.controller/conversation.middleware.js b/src/controller/conversation.controller/conversation.middleware.js new file mode 100644 index 000000000..25e68768d --- /dev/null +++ b/src/controller/conversation.controller/conversation.middleware.js @@ -0,0 +1,37 @@ +const { validationResult } = require('express-validator') +const utils = require('../../utils/utils') +const errors = require('./error') +const error = new errors.ConversationControllerError() + +function parseGetParams (req, res, next) { + utils.reqCtxMapping(req, 'query', ['page']) + next() +} + +function parseTargetParams (req, res, next) { + utils.reqCtxMapping(req, 'params', ['uuid']) + utils.reqCtxMapping(req, 'query', ['page']) + next() +} + +function parseUuidParams (req, res, next) { + utils.reqCtxMapping(req, 'params', ['uuid']) + next() +} + +function parseError (req, res, next) { + const err = validationResult(req).formatWith(({ location, msg, param, value, nestedErrors }) => { + return { msg: msg, param: param, location: location } + }) + if (!err.isEmpty()) { + return res.status(400).json(error.badInput(err.array())) + } + next() +} + +module.exports = { + parseGetParams, + parseTargetParams, + parseUuidParams, + parseError +} diff --git a/src/controller/conversation.controller/index.js b/src/controller/conversation.controller/index.js index 1816d797f..1a5966389 100644 --- a/src/controller/conversation.controller/index.js +++ b/src/controller/conversation.controller/index.js @@ -1,6 +1,7 @@ const router = require('express').Router() const { param, query } = require('express-validator') const controller = require('./conversation.controller') +const { parseGetParams, parseTargetParams, parseUuidParams, parseError } = require('./conversation.middleware') const mw = require('../../middleware/middleware') const getConstants = require('../../../src/constants').getConstants const CONSTANTS = getConstants() @@ -100,6 +101,8 @@ router.get('/conversation', query().custom((query) => { return mw.validateQueryParameterNames(query, ['page']) }), query(['page']).custom((val) => { return mw.containsNoInvalidCharacters(val) }), query(['page']).optional().isInt({ min: CONSTANTS.PAGINATOR_PAGE }), + parseError, + parseGetParams, controller.getAllConversations ) @@ -199,6 +202,8 @@ router.get('/conversation/target/:uuid', query().custom((query) => { return mw.validateQueryParameterNames(query, ['page']) }), query(['page']).custom((val) => { return mw.containsNoInvalidCharacters(val) }), query(['page']).optional().isInt({ min: CONSTANTS.PAGINATOR_PAGE }), + parseError, + parseTargetParams, controller.getConversationsForTargetUUID ) @@ -301,6 +306,8 @@ router.post('/conversation/target/:uuid', mw.validateUser, mw.onlySecretariatOrAdmin, param(['uuid']).isUUID(4), + parseError, + parseUuidParams, controller.createConversationForTargetUUID ) @@ -410,6 +417,8 @@ router.put('/conversation/:uuid', mw.validateUser, mw.onlySecretariat, param(['uuid']).isUUID(4), + parseError, + parseUuidParams, controller.updateConversationByUUID ) diff --git a/test/integration-tests/conversation/conversationTest.js b/test/integration-tests/conversation/conversationTest.js index bac7e0e47..770098a23 100644 --- a/test/integration-tests/conversation/conversationTest.js +++ b/test/integration-tests/conversation/conversationTest.js @@ -3,9 +3,11 @@ const chai = require('chai') const expect = chai.expect chai.use(require('chai-http')) +const { v4: uuidv4 } = require('uuid') const constants = require('../constants.js') const app = require('../../../src/index.js') +const ConversationModel = require('../../../src/model/conversation') const namedSecretariatHeaders = { ...constants.headers, @@ -140,6 +142,42 @@ describe('Testing Conversation endpoints', () => { }) }) }) + it('Should respect the requested page when getting all conversations', async () => { + const paginationTargetUUID = uuidv4() + const conversationCount = 501 + const conversations = Array.from({ length: conversationCount }, (_, index) => ({ + UUID: uuidv4(), + target_uuid: paginationTargetUUID, + previous_conversation_uuid: index === 0 ? null : `pagination-conversation-${index - 1}`, + next_conversation_uuid: index === conversationCount - 1 ? null : `pagination-conversation-${index + 1}`, + author_id: secUserUUID, + author_name: 'Secretariat', + author_role: 'Secretariat', + visibility: 'public', + body: `pagination conversation ${index}`, + posted_at: new Date(Date.UTC(2026, 0, 1, 0, 0, index)) + })) + + try { + await ConversationModel.insertMany(conversations) + + await chai.request(app) + .get('/api/conversation?page=2') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + + expect(res.body).to.haveOwnProperty('conversations') + expect(res.body.conversations).to.be.an('array').that.is.not.empty + expect(res.body).to.have.property('itemsPerPage', 500) + expect(res.body).to.have.property('currentPage', 2) + expect(res.body).to.have.property('prevPage', 1) + }) + } finally { + await ConversationModel.deleteMany({ target_uuid: paginationTargetUUID }) + } + }) it('Should get and see all conversations for target UUID as Secretariat', async () => { await chai.request(app) .get(`/api/conversation/target/${orgUUID}`) @@ -208,12 +246,72 @@ describe('Testing Conversation endpoints', () => { }) context('Negative Tests', () => { + it('Should fail to get all conversations with an invalid page query parameter', async () => { + await chai.request(app) + .get('/api/conversation?page=not-a-number') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body).to.have.property('error', 'BAD_INPUT') + expect(res.body).to.have.property('message', 'Parameters were invalid') + expect(res.body.details).to.be.an('array').that.is.not.empty + expect(res.body.details[0]).to.include({ param: 'page', location: 'query' }) + }) + }) + it('Should fail to get all conversations with an unknown query parameter', async () => { + await chai.request(app) + .get('/api/conversation?unexpected=true') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body).to.have.property('error', 'BAD_INPUT') + expect(res.body).to.have.property('message', 'Parameters were invalid') + expect(res.body.details).to.be.an('array').that.is.not.empty + expect(res.body.details[0]).to.include({ msg: "'unexpected' is not a valid parameter name.", location: 'query' }) + }) + }) + it('Should fail to post a conversation with an invalid target UUID', async () => { + await chai.request(app) + .post('/api/conversation/target/not-a-uuid') + .set(constants.headers) + .send({ + visibility: 'public', + body: 'test' + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body).to.have.property('error', 'BAD_INPUT') + expect(res.body).to.have.property('message', 'Parameters were invalid') + expect(res.body.details).to.be.an('array').that.is.not.empty + expect(res.body.details[0]).to.include({ param: 'uuid', location: 'params' }) + }) + }) + it('Should fail to update a conversation with an invalid UUID', async () => { + await chai.request(app) + .put('/api/conversation/not-a-uuid') + .set(constants.headers) + .send({ + body: 'test updated', + visibility: 'private' + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body).to.have.property('error', 'BAD_INPUT') + expect(res.body).to.have.property('message', 'Parameters were invalid') + expect(res.body.details).to.be.an('array').that.is.not.empty + expect(res.body.details[0]).to.include({ param: 'uuid', location: 'params' }) + }) + }) it('Should fail to post a conversation to a different org as a non-Secretariat Admin', async () => { const conversation = { visibility: 'public', body: 'test' } - const randomUUID = '123e4567-e89b-12d3-a456-426614174000' + const randomUUID = uuidv4() await chai.request(app) .post(`/api/conversation/target/${randomUUID}`) .set(constants.nonSecretariatUserHeaders2) // Admin of win_5 @@ -255,8 +353,10 @@ describe('Testing Conversation endpoints', () => { }) }) it('Should fail to update a conversation that does not exist', async () => { + const nonExistentConversationUUID = uuidv4() + await chai.request(app) - .put('/api/conversation/non-existent-uuid') + .put(`/api/conversation/${nonExistentConversationUUID}`) .set(constants.headers) .send({ body: 'test updated', @@ -267,7 +367,7 @@ describe('Testing Conversation endpoints', () => { expect(res).to.have.status(404) expect(res.body).to.haveOwnProperty('message') - expect(res.body.message).to.equal('The conversation with UUID non-existent-uuid does not exist.') + expect(res.body.message).to.equal(`The conversation with UUID ${nonExistentConversationUUID} does not exist.`) }) }) it('Should fail to update a conversation with invalid body', async () => { From ef5e5e77932ae4e8476e8e6c4b4fef310436fa8d Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 29 Jun 2026 15:51:44 -0400 Subject: [PATCH 04/19] futureproof registry only users --- src/repositories/baseUserRepository.js | 7 +- test/integration-tests/user/updateUserTest.js | 135 +++++++++++++++++- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index 36e057b30..1875dd6c9 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -676,8 +676,11 @@ class BaseUserRepository extends BaseRepository { const baseOrgRepository = new BaseOrgRepository() const { createAuditLogEntry } = require('./baseOrgRepositoryHelpers') - // Grab current org UUID using whichever document is real - const currentOrgUUID = legacyUser ? legacyUser.org_UUID : registryUser?.org_UUID // Fallback if schema supports it + // Prefer the legacy association while dual-write is active, then fall back to registry org membership. + let currentOrgUUID = legacyUser?.org_UUID || registryUser?.org_UUID + if (!currentOrgUUID) { + currentOrgUUID = await baseOrgRepository.getOrgUUIDByUserUUID(identifier, options) + } const currentOrg = currentOrgUUID ? await baseOrgRepository.findOneByUUID(currentOrgUUID) : null const newOrg = await baseOrgRepository.findOneByShortName(incomingUser.org_short_name) const wasAdmin = currentOrg?.admins?.includes(identifier) ?? false diff --git a/test/integration-tests/user/updateUserTest.js b/test/integration-tests/user/updateUserTest.js index 24cd8cb19..12f42db05 100644 --- a/test/integration-tests/user/updateUserTest.js +++ b/test/integration-tests/user/updateUserTest.js @@ -4,9 +4,70 @@ const chai = require('chai') chai.use(require('chai-http')) const expect = chai.expect +const { v4: uuidv4 } = require('uuid') const constants = require('../constants.js') const app = require('../../../src/index.js') +const BaseOrgModel = require('../../../src/model/baseorg') +const UserModel = require('../../../src/model/user') + +const makeShortName = (prefix) => `${prefix}_${uuidv4().replace(/-/g, '').slice(0, 16)}` + +const postRegistryOrg = async (shortName) => { + await chai.request(app) + .post('/api/registry/org') + .set(constants.headers) + .send({ + short_name: shortName, + long_name: `${shortName} Organization`, + authority: ['CNA'], + id_quota: 1000 + }) + .then(res => { + expect(res.status).to.equal(200, JSON.stringify(res.body)) + }) +} + +const postRegistryUser = async (orgShortName, username, role) => { + const body = { + username, + name: { + first: 'Registry', + last: 'Only' + }, + status: 'active' + } + + if (role) { + body.role = role + } + + await chai.request(app) + .post(`/api/registry/org/${orgShortName}/user`) + .set(constants.headers) + .send(body) + .then(res => { + expect(res.status).to.equal(200, JSON.stringify(res.body)) + }) +} + +const getRegistryUser = async (orgShortName, username) => { + let user + await chai.request(app) + .get(`/api/registry/org/${orgShortName}/user/${username}`) + .set(constants.headers) + .then(res => { + expect(res.status).to.equal(200, JSON.stringify(res.body)) + user = res.body + }) + + delete user.created + delete user.created_by + delete user.last_updated + return user +} + +const countOccurrences = (values = [], value) => values.filter(item => item === value).length describe('Testing Edit user endpoint', () => { context('Positive Tests', () => { @@ -58,7 +119,6 @@ describe('Testing Edit user endpoint', () => { expect(res.status).to.equal(200, JSON.stringify(res.body)) }) // Verify user is in orgA's admins array - const BaseOrgModel = require('../../../src/model/baseorg') const orgARecordBefore = await BaseOrgModel.findOne({ short_name: orgA }) expect(orgARecordBefore.admins).to.be.an('array') // We need the user's UUID @@ -92,6 +152,79 @@ describe('Testing Edit user endpoint', () => { expect(orgBRecordAfter.admins).to.include(userUUID) }) + it('Should reassign a registry-only user without leaving old organization membership', async () => { + const orgA = makeShortName('regonlya') + const orgB = makeShortName('regonlyb') + const username = `registryonly_${uuidv4().replace(/-/g, '').slice(0, 16)}@example.com` + + await postRegistryOrg(orgA) + await postRegistryOrg(orgB) + await postRegistryUser(orgA, username) + + const userResponse = await getRegistryUser(orgA, username) + const userUUID = userResponse.UUID + const orgARecordBefore = await BaseOrgModel.findOne({ short_name: orgA }) + expect(orgARecordBefore.users).to.include(userUUID) + + const deleteResult = await UserModel.deleteOne({ UUID: userUUID }) + expect(deleteResult.deletedCount).to.equal(1) + + await chai.request(app) + .put(`/api/registry/org/${orgA}/user/${username}`) + .set(constants.headers) + .send({ + ...userResponse, + org_short_name: orgB + }) + .then((res) => { + expect(res.status).to.equal(200, JSON.stringify(res.body)) + }) + + const orgARecordAfter = await BaseOrgModel.findOne({ short_name: orgA }) + const orgBRecordAfter = await BaseOrgModel.findOne({ short_name: orgB }) + expect(orgARecordAfter.users).to.not.include(userUUID) + expect(orgARecordAfter.admins || []).to.not.include(userUUID) + expect(countOccurrences(orgBRecordAfter.users, userUUID)).to.equal(1) + expect(orgBRecordAfter.admins || []).to.not.include(userUUID) + }) + + it('Should reassign a registry-only admin without leaving old organization admin membership', async () => { + const orgA = makeShortName('regadmina') + const orgB = makeShortName('regadminb') + const username = `registryadmin_${uuidv4().replace(/-/g, '').slice(0, 16)}@example.com` + + await postRegistryOrg(orgA) + await postRegistryOrg(orgB) + await postRegistryUser(orgA, username, 'ADMIN') + + const userResponse = await getRegistryUser(orgA, username) + const userUUID = userResponse.UUID + const orgARecordBefore = await BaseOrgModel.findOne({ short_name: orgA }) + expect(orgARecordBefore.users).to.include(userUUID) + expect(orgARecordBefore.admins).to.include(userUUID) + + const deleteResult = await UserModel.deleteOne({ UUID: userUUID }) + expect(deleteResult.deletedCount).to.equal(1) + + await chai.request(app) + .put(`/api/registry/org/${orgA}/user/${username}`) + .set(constants.headers) + .send({ + ...userResponse, + org_short_name: orgB + }) + .then((res) => { + expect(res.status).to.equal(200, JSON.stringify(res.body)) + }) + + const orgARecordAfter = await BaseOrgModel.findOne({ short_name: orgA }) + const orgBRecordAfter = await BaseOrgModel.findOne({ short_name: orgB }) + expect(orgARecordAfter.users).to.not.include(userUUID) + expect(orgARecordAfter.admins).to.not.include(userUUID) + expect(countOccurrences(orgBRecordAfter.users, userUUID)).to.equal(1) + expect(countOccurrences(orgBRecordAfter.admins, userUUID)).to.equal(1) + }) + it('Should return 200 when only name changes are done', async () => { await chai.request(app) .put('/api/org/win_5/user/jasminesmith@win_5.com?name.first=NewName') From f55d7d8ecaa3dd44a1eb1d04870c96d31156a25f Mon Sep 17 00:00:00 2001 From: david-rocca Date: Mon, 29 Jun 2026 16:12:43 -0400 Subject: [PATCH 05/19] Fixing some gt / lt searching --- .../cve.controller/cve.controller.js | 16 ++- .../cve/getCveCnaModifiedTest.js | 77 +++++++++++++ test/unit-tests/cve/cveGetAllTest.js | 104 ++++++++++++++++++ 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/src/controller/cve.controller/cve.controller.js b/src/controller/cve.controller/cve.controller.js index 0ac52b983..b438fa4dc 100644 --- a/src/controller/cve.controller/cve.controller.js +++ b/src/controller/cve.controller/cve.controller.js @@ -119,12 +119,15 @@ async function getFilteredCves (req, res, next) { const query = {} if (timeModified.timeStamp.length > 0) { - if (!cnaModified) { query['time.modified'] = {} } + if (cnaModified) { + query['cve.containers.cna.providerMetadata.dateUpdated'] = {} + } else { + query['time.modified'] = {} + } for (let i = 0; i < timeModified.timeStamp.length; i++) { if (timeModified.dateOperator[i] === 'lt') { if (cnaModified) { - query['cve.containers.cna.providerMetadata.dateUpdated'] = {} // Due to this not being the mongo created date object, we need to actually check the "ISO String" version of this _NOT_ the date object that is being created in the middleware query['cve.containers.cna.providerMetadata.dateUpdated'].$lt = timeModifiedLtDateObject.toISOString() } else { @@ -132,7 +135,6 @@ async function getFilteredCves (req, res, next) { } } else { if (cnaModified) { - query['cve.containers.cna.providerMetadata.dateUpdated'] = {} // Due to this not being the mongo created date object, we need to actually check the "ISO String" version of this _NOT_ the date object that is being created in the middleware query['cve.containers.cna.providerMetadata.dateUpdated'].$gt = timeModifiedGtDateObject.toISOString() } else { @@ -294,12 +296,15 @@ async function getFilteredCvesCursor (req, res, next) { const query = {} if (timeModified.timeStamp.length > 0) { - if (!cnaModified) { query['time.modified'] = {} } + if (cnaModified) { + query['cve.containers.cna.providerMetadata.dateUpdated'] = {} + } else { + query['time.modified'] = {} + } for (let i = 0; i < timeModified.timeStamp.length; i++) { if (timeModified.dateOperator[i] === 'lt') { if (cnaModified) { - query['cve.containers.cna.providerMetadata.dateUpdated'] = {} // Due to this not being the mongo created date object, we need to actually check the "ISO String" version of this _NOT_ the date object that is being created in the middleware query['cve.containers.cna.providerMetadata.dateUpdated'].$lt = timeModifiedLtDateObject.toISOString() } else { @@ -307,7 +312,6 @@ async function getFilteredCvesCursor (req, res, next) { } } else { if (cnaModified) { - query['cve.containers.cna.providerMetadata.dateUpdated'] = {} // Due to this not being the mongo created date object, we need to actually check the "ISO String" version of this _NOT_ the date object that is being created in the middleware query['cve.containers.cna.providerMetadata.dateUpdated'].$gt = timeModifiedGtDateObject.toISOString() } else { diff --git a/test/integration-tests/cve/getCveCnaModifiedTest.js b/test/integration-tests/cve/getCveCnaModifiedTest.js index d3ffbc1e4..81db5ddfb 100644 --- a/test/integration-tests/cve/getCveCnaModifiedTest.js +++ b/test/integration-tests/cve/getCveCnaModifiedTest.js @@ -11,11 +11,66 @@ const _ = require('lodash') const shortName = 'win_5' +function wait (milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)) +} + +function addMilliseconds (dateString, milliseconds) { + return new Date(Date.parse(dateString) + milliseconds).toISOString() +} + +async function createCveAndGetCnaUpdatedDate (cveId) { + return await chai.request(app) + .post(`/api/cve/${cveId}/cna`) + .set(constants.nonSecretariatUserHeaders) + .send(constants.testCve) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + return res.body.created.containers.cna.providerMetadata.dateUpdated + }) +} + describe('Test cna_modified parameter for get CVE', () => { let cveId + let beforeWindowCveId + let insideWindowCveId + let afterWindowCveId + let cnaModifiedLowerBound + let cnaModifiedUpperBound + + function cnaModifiedWindowQuery () { + return `time_modified.gt=${encodeURIComponent(cnaModifiedLowerBound)}&time_modified.lt=${encodeURIComponent(cnaModifiedUpperBound)}&cna_modified=true` + } + + function expectOnlyCvesInsideCnaModifiedWindow (records) { + expect(_.some(records, { cveMetadata: { cveId: insideWindowCveId } })).to.be.true + expect(_.some(records, { cveMetadata: { cveId: beforeWindowCveId } })).to.be.false + expect(_.some(records, { cveMetadata: { cveId: afterWindowCveId } })).to.be.false + + records.forEach(record => { + const cnaDateUpdated = Date.parse(record.containers.cna.providerMetadata.dateUpdated) + expect(cnaDateUpdated).to.be.greaterThan(Date.parse(cnaModifiedLowerBound)) + expect(cnaDateUpdated).to.be.lessThan(Date.parse(cnaModifiedUpperBound)) + }) + } + before(async () => { cveId = await helpers.cveIdReserveHelper(1, '2023', shortName, 'non-sequential') await helpers.cveRequestAsCnaHelper(cveId) + + beforeWindowCveId = await helpers.cveIdReserveHelper(1, '2023', shortName, 'non-sequential') + const beforeWindowDate = await createCveAndGetCnaUpdatedDate(beforeWindowCveId) + cnaModifiedLowerBound = addMilliseconds(beforeWindowDate, 1) + + await wait(20) + insideWindowCveId = await helpers.cveIdReserveHelper(1, '2023', shortName, 'non-sequential') + const insideWindowDate = await createCveAndGetCnaUpdatedDate(insideWindowCveId) + cnaModifiedUpperBound = addMilliseconds(insideWindowDate, 1) + + await wait(20) + afterWindowCveId = await helpers.cveIdReserveHelper(1, '2023', shortName, 'non-sequential') + await createCveAndGetCnaUpdatedDate(afterWindowCveId) }) context('Positive Test', () => { it('Get CVE with cna_modified set to true AND date.gt should return when searched with a known earlier than date', async () => { @@ -59,6 +114,28 @@ describe('Test cna_modified parameter for get CVE', () => { expect(_.some(res.body.cveRecords, { cveMetadata: { cveId: cveId } })).to.be.false }) }) + + it('Get CVE with cna_modified set to true AND date.gt/date.lt should only return records inside the CNA modified date window', async () => { + await chai.request(app) + .get(`/api/cve/?${cnaModifiedWindowQuery()}`) + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expectOnlyCvesInsideCnaModifiedWindow(res.body.cveRecords) + }) + }) + + it('Get cve_cursor with cna_modified set to true AND date.gt/date.lt should only return records inside the CNA modified date window', async () => { + await chai.request(app) + .get(`/api/cve_cursor?${cnaModifiedWindowQuery()}`) + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expectOnlyCvesInsideCnaModifiedWindow(res.body.cveRecords) + }) + }) }) context('Negative Tests', () => { it('CVE should NOT be returned with cna_modified true as it has been created', async () => { diff --git a/test/unit-tests/cve/cveGetAllTest.js b/test/unit-tests/cve/cveGetAllTest.js index fea354ce6..dc5d72ea7 100644 --- a/test/unit-tests/cve/cveGetAllTest.js +++ b/test/unit-tests/cve/cveGetAllTest.js @@ -19,6 +19,110 @@ const cveParams = require('../../../src/controller/cve.controller/cve.middleware describe('Testing the GET /cve endpoint in Cve Controller', () => { context('Positive Tests', () => { + it('Builds a cna_modified query with both time_modified bounds', async () => { + const lowerBound = new Date('2026-01-01T00:00:00.000Z') + const upperBound = new Date('2026-02-01T00:00:00.000Z') + let builtQuery = null + + class MyCvePositiveTests { + async aggregatePaginate (aggregation) { + builtQuery = aggregation[0].$match + + return { + itemsList: [], + itemCount: 0, + itemsPerPage: 500, + currentPage: 1, + pageCount: 1, + prevPage: null, + nextPage: null + } + } + } + + const req = { + ctx: { + uuid: 'unit-test', + query: { + 'time_modified.gt': lowerBound, + 'time_modified.lt': upperBound, + cna_modified: 'true' + }, + repositories: { + getCveRepository: () => { return new MyCvePositiveTests() } + } + } + } + + let statusCode = null + const res = { + status: (code) => { + statusCode = code + return res + }, + json: () => {} + } + + await cveController.CVE_GET_FILTERED(req, res, (err) => { throw err }) + + expect(statusCode).to.equal(200) + expect(builtQuery['cve.containers.cna.providerMetadata.dateUpdated']).to.deep.equal({ + $gt: lowerBound.toISOString(), + $lt: upperBound.toISOString() + }) + }) + + it('Builds a cna_modified cursor query with both time_modified bounds', async () => { + const lowerBound = new Date('2026-01-01T00:00:00.000Z') + const upperBound = new Date('2026-02-01T00:00:00.000Z') + let builtQuery = null + + class MyCvePositiveTests { + async cursorPaginate (query) { + builtQuery = query + + return { + results: [], + hasNext: false, + hasPrevious: false, + next: null, + previous: null + } + } + } + + const req = { + ctx: { + uuid: 'unit-test', + query: { + 'time_modified.gt': lowerBound, + 'time_modified.lt': upperBound, + cna_modified: 'true' + }, + repositories: { + getCveRepository: () => { return new MyCvePositiveTests() } + } + } + } + + let statusCode = null + const res = { + status: (code) => { + statusCode = code + return res + }, + json: () => {} + } + + await cveController.CVE_GET_FILTERED_CURSOR(req, res, (err) => { throw err }) + + expect(statusCode).to.equal(200) + expect(builtQuery['cve.containers.cna.providerMetadata.dateUpdated']).to.deep.equal({ + $gt: lowerBound.toISOString(), + $lt: upperBound.toISOString() + }) + }) + it('JSON schema v5.0 returned: The secretariat gets a list of non-paginated cve records', (done) => { const itemsPerPage = 500 From c5b06c28f0161e6ae59ab8f7092d9bf1249a1624 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 30 Jun 2026 11:04:21 -0400 Subject: [PATCH 06/19] fixing wrongly named index --- src/model/cve.js | 2 +- src/scripts/populate.js | 6 ++- test/integration-tests/cve/cveIndexesTest.js | 43 ++++++++++++++++++++ test/unit-tests/cve/cveModelIndexesTest.js | 17 ++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 test/integration-tests/cve/cveIndexesTest.js create mode 100644 test/unit-tests/cve/cveModelIndexesTest.js diff --git a/src/model/cve.js b/src/model/cve.js index 81f1eee87..e81a1293c 100644 --- a/src/model/cve.js +++ b/src/model/cve.js @@ -28,7 +28,7 @@ CveSchema.query.byCveId = function (id) { CveSchema.index({ 'cve.cveMetadata.cveId': 1 }) CveSchema.index({ 'cve.cveMetadata.dateUpdated': 1 }) -CveSchema.index({ 'cve.containers.cna.provderMetadata.dateUpdated': 1 }) +CveSchema.index({ 'cve.containers.cna.providerMetadata.dateUpdated': 1 }) CveSchema.index({ 'time.modified': 1 }) CveSchema.index({ 'time.created': 1 }) diff --git a/src/scripts/populate.js b/src/scripts/populate.js index b92659901..a51d3046a 100644 --- a/src/scripts/populate.js +++ b/src/scripts/populate.js @@ -40,7 +40,11 @@ const populateTheseCollections = { } const indexesToCreate = { - Cve: [{ 'cve.cveMetadata.cveId': 1 }, { 'cve.cveMetadata.dateUpdated': 1 }], + Cve: [ + { 'cve.cveMetadata.cveId': 1 }, + { 'cve.cveMetadata.dateUpdated': 1 }, + { 'cve.containers.cna.providerMetadata.dateUpdated': 1 } + ], 'Cve-Id': [{ cve_id: 1 }, { owning_cna: 1, state: 1 }, { reserved: 1 }], User: [{ UUID: 1 }], Org: [{ UUID: 1 }, { 'authority.active_roles': 1 }], diff --git a/test/integration-tests/cve/cveIndexesTest.js b/test/integration-tests/cve/cveIndexesTest.js new file mode 100644 index 000000000..f40f6fa2d --- /dev/null +++ b/test/integration-tests/cve/cveIndexesTest.js @@ -0,0 +1,43 @@ +const chai = require('chai') +const expect = chai.expect +const mongoose = require('mongoose') + +require('../../../src/index.js') + +function waitForMongoConnection () { + if (mongoose.connection.readyState === 1) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for MongoDB connection')) + }, 10000) + + mongoose.connection.once('open', () => { + clearTimeout(timeout) + resolve() + }) + + mongoose.connection.once('error', err => { + clearTimeout(timeout) + reject(err) + }) + }) +} + +describe('Test CVE collection indexes', () => { + it('Creates the CNA provider metadata dateUpdated index', async () => { + await waitForMongoConnection() + + const indexes = await mongoose.connection.db.collection('Cve').indexes() + const indexFields = indexes.map(index => index.key) + + expect(indexFields).to.deep.include({ + 'cve.containers.cna.providerMetadata.dateUpdated': 1 + }) + expect(indexFields).to.not.deep.include({ + 'cve.containers.cna.provderMetadata.dateUpdated': 1 + }) + }) +}) diff --git a/test/unit-tests/cve/cveModelIndexesTest.js b/test/unit-tests/cve/cveModelIndexesTest.js new file mode 100644 index 000000000..e38d7810d --- /dev/null +++ b/test/unit-tests/cve/cveModelIndexesTest.js @@ -0,0 +1,17 @@ +const chai = require('chai') +const expect = chai.expect + +const Cve = require('../../../src/model/cve') + +describe('Testing the Cve model indexes', () => { + it('Defines the CNA provider metadata dateUpdated index', () => { + const indexFields = Cve.schema.indexes().map(([fields]) => fields) + + expect(indexFields).to.deep.include({ + 'cve.containers.cna.providerMetadata.dateUpdated': 1 + }) + expect(indexFields).to.not.deep.include({ + 'cve.containers.cna.provderMetadata.dateUpdated': 1 + }) + }) +}) From b333978d62cf002eb48a920e723d18bbc41ca4cf Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 30 Jun 2026 12:47:19 -0400 Subject: [PATCH 07/19] Fixing trimJSONWHITESPACE to avoiud queuing null values --- src/middleware/middleware.js | 4 +- .../middleware/trimJSONWhitespaceTest.js | 55 ++++++++++++++ .../middleware/trimJSONWhitespaceTest.js | 72 +++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 test/integration-tests/middleware/trimJSONWhitespaceTest.js diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index 1611594e9..7682b2672 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -424,13 +424,13 @@ function trimJSONWhitespace (req, res, next) { if (typeof value === 'string') { currentObject[key] = value.trim() - } else if (typeof value === 'object') { + } else if (value !== null && typeof value === 'object') { queue.push(value) // Add nested objects to the queue } } } } catch (err) { - next(err) + return next(err) } next() diff --git a/test/integration-tests/middleware/trimJSONWhitespaceTest.js b/test/integration-tests/middleware/trimJSONWhitespaceTest.js new file mode 100644 index 000000000..da49cb7f9 --- /dev/null +++ b/test/integration-tests/middleware/trimJSONWhitespaceTest.js @@ -0,0 +1,55 @@ +const express = require('express') +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-http')) + +const middleware = require('../../../src/middleware/middleware') + +function createTrimJSONWhitespaceTestApp () { + const app = express() + + app.use(express.json()) + app.post('/trim-json-whitespace', middleware.trimJSONWhitespace, (req, res) => { + return res.status(200).json(req.body) + }) + + return app +} + +describe('Trim JSON whitespace middleware integration', () => { + it('continues normally with nested null values and trims nested strings', async () => { + const app = createTrimJSONWhitespaceTestApp() + + const res = await chai.request(app) + .post('/trim-json-whitespace') + .set('content-type', 'application/json') + .send({ + field1: { + nestedNull: null, + nestedString: ' Test Name ', + nestedArray: [ + ' array value ', + { + nestedArrayString: ' nested array object value ' + }, + null + ] + } + }) + + expect(res).to.have.status(200) + expect(res.body).to.deep.equal({ + field1: { + nestedNull: null, + nestedString: 'Test Name', + nestedArray: [ + 'array value', + { + nestedArrayString: 'nested array object value' + }, + null + ] + } + }) + }) +}) diff --git a/test/unit-tests/middleware/trimJSONWhitespaceTest.js b/test/unit-tests/middleware/trimJSONWhitespaceTest.js index 900c53c8f..9ce9fb21a 100644 --- a/test/unit-tests/middleware/trimJSONWhitespaceTest.js +++ b/test/unit-tests/middleware/trimJSONWhitespaceTest.js @@ -24,6 +24,7 @@ describe('Testing trimJSONWhitespace middleware', () => { trimJSONWhitespace(req, res, next) expect(req.body).to.be.an('object') expect(req.body).to.deep.equal({ field1: 'this has whitespace', field2: 'trailing whitespace only' }) + expect(next.calledOnceWithExactly()).to.equal(true) }) it('Should successfully trim leading/trailing whitespace for a nested JSON object', async () => { @@ -51,6 +52,7 @@ describe('Testing trimJSONWhitespace middleware', () => { } } }) + expect(next.calledOnceWithExactly()).to.equal(true) }) it('Should ignore non-string and non-object values', async () => { @@ -63,5 +65,75 @@ describe('Testing trimJSONWhitespace middleware', () => { trimJSONWhitespace(req, res, next) expect(req.body).to.be.an('object') expect(req.body).to.deep.equal({ test: 'Test Name', numberTest: 25 }) + expect(next.calledOnceWithExactly()).to.equal(true) + }) + + it('Should preserve nested null values while trimming strings', async () => { + const req = { + body: { + field1: { + nestedNull: null, + nestedString: ' Test Name ' + }, + topLevelNull: null + } + } + + trimJSONWhitespace(req, res, next) + + expect(req.body).to.deep.equal({ + field1: { + nestedNull: null, + nestedString: 'Test Name' + }, + topLevelNull: null + }) + expect(next.calledOnceWithExactly()).to.equal(true) + }) + + it('Should trim strings in arrays and nested objects', async () => { + const req = { + body: { + field1: [ + ' array value ', + { + nestedString: ' nested array object value ' + }, + null, + 25 + ] + } + } + + trimJSONWhitespace(req, res, next) + + expect(req.body).to.deep.equal({ + field1: [ + 'array value', + { + nestedString: 'nested array object value' + }, + null, + 25 + ] + }) + expect(next.calledOnceWithExactly()).to.equal(true) + }) + + it('Should call next with an error once and stop processing when trimming fails', async () => { + const expectedError = new Error('Unable to read value') + const body = {} + Object.defineProperty(body, 'badValue', { + enumerable: true, + get: () => { + throw expectedError + } + }) + const req = { body } + + trimJSONWhitespace(req, res, next) + + expect(next.calledOnce).to.equal(true) + expect(next.firstCall.args).to.deep.equal([expectedError]) }) }) From 0f85f107394c1b15c878a5ba474eabb8c6f1f469 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 30 Jun 2026 12:58:33 -0400 Subject: [PATCH 08/19] Prevent append-audit requests without history from returning a 500 --- .../audit.controller/audit.controller.js | 5 +++++ test/integration-tests/audit/auditTest.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/controller/audit.controller/audit.controller.js b/src/controller/audit.controller/audit.controller.js index 205bd5c46..23b3573b3 100644 --- a/src/controller/audit.controller/audit.controller.js +++ b/src/controller/audit.controller/audit.controller.js @@ -133,6 +133,11 @@ async function appendToAuditHistoryForOrg (req, res, next) { return res.status(400).json(error.invalidUUID('target_uuid')) } + if (!Array.isArray(body.history) || body.history.length === 0) { + logger.info({ uuid: req.ctx.uuid, message: 'Missing required field: history' }) + return res.status(400).json(error.missingRequiredField('history')) + } + const repo = req.ctx.repositories.getAuditRepository() const orgRepo = req.ctx.repositories.getBaseOrgRepository() const session = await mongoose.startSession() diff --git a/test/integration-tests/audit/auditTest.js b/test/integration-tests/audit/auditTest.js index cd6967b1c..92e6db2ea 100644 --- a/test/integration-tests/audit/auditTest.js +++ b/test/integration-tests/audit/auditTest.js @@ -321,6 +321,25 @@ describe('Testing Audit Org endpoints', () => { }) }) + it('Should fail to append audit without history', async () => { + const appendData = { + target_uuid: orgUuid + } + + await chai.request(app) + .put('/api/audit/org/') + .set(constants.headers) + .send(appendData) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + + expect(res.body).to.haveOwnProperty('error') + expect(res.body.error).to.equal('MISSING_REQUIRED_FIELD') + expect(res.body.message).to.equal("Missing required field: 'history'.") + }) + }) + it('Should fail to create audit when uuid is provided', async () => { const auditData = { uuid: uuid.v4(), From 449404f523ab40e889d21ace3309bd2d70f3a559 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 30 Jun 2026 13:32:24 -0400 Subject: [PATCH 09/19] validates cursor pagination limits correctly --- src/controller/cve.controller/index.js | 2 +- .../cve/cursorPaginationTest.js | 46 +++++++++++++++++++ test/unit-tests/cve/cveGetAllTest.js | 45 ++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/controller/cve.controller/index.js b/src/controller/cve.controller/index.js index c5a78503e..32c8e6ab5 100644 --- a/src/controller/cve.controller/index.js +++ b/src/controller/cve.controller/index.js @@ -413,7 +413,7 @@ router.get('/cve_cursor', query(['assigner']).optional().isString().trim().notEmpty(), query(['cna_modified']).optional().isBoolean({ loose: true }).withMessage(errorMsgs.CNA_MODIFIED), query(['adp_short_name']).optional().isString().trim().notEmpty().isLength({ min: CONSTANTS.MIN_SHORTNAME_LENGTH, max: CONSTANTS.MAX_SHORTNAME_LENGTH }), - query(['limit']).optional().isString().trim().notEmpty().isLength({ min: 1, max: CONSTANTS.PAGINATOR_OPTIONS.limit }), + query(['limit']).optional().not().isArray().trim().isInt({ min: 1, max: CONSTANTS.PAGINATOR_OPTIONS.limit }).toInt(), parseError, parseGetParams, controller.CVE_GET_FILTERED_CURSOR) diff --git a/test/integration-tests/cve/cursorPaginationTest.js b/test/integration-tests/cve/cursorPaginationTest.js index 07216c967..b43e8807d 100644 --- a/test/integration-tests/cve/cursorPaginationTest.js +++ b/test/integration-tests/cve/cursorPaginationTest.js @@ -7,8 +7,10 @@ const helpers = require('../helpers.js') const expect = chai.expect const constants = require('../constants.js') +const getConstants = require('../../../src/constants').getConstants const app = require('../../../src/index.js') const currentDate = new Date().toISOString() +const PAGINATOR_LIMIT = getConstants().PAGINATOR_OPTIONS.limit describe('Testing Get cve_cursor endpoint', () => { let cveIds = [] @@ -211,4 +213,48 @@ describe('Testing Get cve_cursor endpoint', () => { requester.close() }).timeout(1000000) }) + + context('Negative Tests', () => { + it('Get cve_cursor should reject non-numeric limit parameter', async () => { + await chai.request(app) + .get('/api/cve_cursor?limit=abc') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body).to.have.property('error', 'BAD_INPUT') + expect(res.body).to.have.property('message', 'Parameters were invalid') + expect(res.body.details).to.be.an('array').that.is.not.empty + expect(res.body.details[0]).to.include({ param: 'limit', location: 'query' }) + }) + }) + + it('Get cve_cursor should reject limit parameter below the minimum', async () => { + await chai.request(app) + .get('/api/cve_cursor?limit=0') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body).to.have.property('error', 'BAD_INPUT') + expect(res.body).to.have.property('message', 'Parameters were invalid') + expect(res.body.details).to.be.an('array').that.is.not.empty + expect(res.body.details[0]).to.include({ param: 'limit', location: 'query' }) + }) + }) + + it('Get cve_cursor should reject limit parameter above the maximum', async () => { + await chai.request(app) + .get(`/api/cve_cursor?limit=${PAGINATOR_LIMIT + 1}`) + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body).to.have.property('error', 'BAD_INPUT') + expect(res.body).to.have.property('message', 'Parameters were invalid') + expect(res.body.details).to.be.an('array').that.is.not.empty + expect(res.body.details[0]).to.include({ param: 'limit', location: 'query' }) + }) + }) + }) }) diff --git a/test/unit-tests/cve/cveGetAllTest.js b/test/unit-tests/cve/cveGetAllTest.js index dc5d72ea7..c4b0021d5 100644 --- a/test/unit-tests/cve/cveGetAllTest.js +++ b/test/unit-tests/cve/cveGetAllTest.js @@ -123,6 +123,51 @@ describe('Testing the GET /cve endpoint in Cve Controller', () => { }) }) + it('Passes sanitized numeric cursor limit to pagination', async () => { + let paginationLimit = null + + class MyCvePositiveTests { + async cursorPaginate (query, limit) { + paginationLimit = limit + + return { + results: [], + hasNext: false, + hasPrevious: false, + next: null, + previous: null + } + } + } + + const req = { + ctx: { + uuid: 'unit-test', + query: { + limit: 2 + }, + repositories: { + getCveRepository: () => { return new MyCvePositiveTests() } + } + } + } + + let statusCode = null + const res = { + status: (code) => { + statusCode = code + return res + }, + json: () => {} + } + + await cveController.CVE_GET_FILTERED_CURSOR(req, res, (err) => { throw err }) + + expect(statusCode).to.equal(200) + expect(paginationLimit).to.equal(2) + expect(paginationLimit).to.be.a('number') + }) + it('JSON schema v5.0 returned: The secretariat gets a list of non-paginated cve records', (done) => { const itemsPerPage = 500 From a61244ffd63d14f59d0ae3ec2ead140d91a618a4 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 30 Jun 2026 13:55:24 -0400 Subject: [PATCH 10/19] Fixes date only query filkter sanitization --- src/utils/utils.js | 8 +++--- test/integration-tests/cve-id/getCveIdTest.js | 21 ++++++++++++++++ test/integration-tests/cve/getCveDateTest.js | 25 +++++++++++++++++++ test/unit-tests/utils/utilsTest.js | 11 ++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/utils/utils.js b/src/utils/utils.js index d30c4252d..c1c66cc71 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -201,9 +201,11 @@ function toDate (val) { } } else { value = val.match(/^\d{4}-\d{2}-\d{2}$/) - /* eslint-disable-next-line */ - if ((value) && DateTime.fromISO(dateStr.toString()).isValid) { - result = new Date(`${value[0]}T00:00:00.000+00:00`) + if (value) { + const dateStr = value[0] + if (DateTime.fromISO(dateStr.toString()).isValid) { + result = new Date(`${dateStr}T00:00:00.000+00:00`) + } } } return result diff --git a/test/integration-tests/cve-id/getCveIdTest.js b/test/integration-tests/cve-id/getCveIdTest.js index 81ff1cc6d..0da515155 100644 --- a/test/integration-tests/cve-id/getCveIdTest.js +++ b/test/integration-tests/cve-id/getCveIdTest.js @@ -37,6 +37,16 @@ describe('Testing Get CVE-ID endpoint', () => { expect(res.body.cve_ids).to.have.length(0) }) }) + it('Get CVE-ID should accept a valid date-only time_modified.gt filter', async () => { + await chai.request(app) + .get('/api/cve-id?time_modified.gt=2100-01-01') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body.cve_ids).to.have.length(0) + }) + }) // Need a better way to test each individual cve-id's time_modified it('Get all CVE-IDs modified within a given timeframe', async () => { await chai.request(app) @@ -284,5 +294,16 @@ describe('Testing Get CVE-ID endpoint', () => { expect(res.body.error).to.contain('BAD_INPUT') }) }) + + it('Date-only Feb 29 2100 should not be valid', async () => { + await chai.request(app) + .get('/api/cve-id?time_modified.gt=2100-02-29') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body.error).to.contain('BAD_INPUT') + }) + }) }) }) diff --git a/test/integration-tests/cve/getCveDateTest.js b/test/integration-tests/cve/getCveDateTest.js index 9e069aeb8..26e16dbbe 100644 --- a/test/integration-tests/cve/getCveDateTest.js +++ b/test/integration-tests/cve/getCveDateTest.js @@ -8,7 +8,32 @@ const constants = require('../constants.js') const app = require('../../../src/index.js') describe('Test time_modified for get CVE', () => { + context('Positive Tests', () => { + it('Get CVE should accept a valid date-only time_modified.gt filter', async () => { + await chai.request(app) + .get('/api/cve?time_modified.gt=2100-01-01') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body.cveRecords).to.be.an('array') + }) + }) + }) + context('Negative Tests', () => { + it('Get CVE should fail cleanly if time_modified.gt is given an invalid date-only value', async () => { + await chai.request(app) + .get('/api/cve?time_modified.gt=2026-02-29') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(400) + expect(res.body.error).to.equal('BAD_INPUT') + expect(res.body.message).to.contain('Parameters were invalid') + }) + }) + it('Get CVE should fail if time_modified.gt is given a date with an invalid month', async () => { await chai.request(app) .get('/api/cve?time_modified.gt=2022-13-01T00:00:00Z') diff --git a/test/unit-tests/utils/utilsTest.js b/test/unit-tests/utils/utilsTest.js index e24bb73e9..6c5f5f899 100644 --- a/test/unit-tests/utils/utilsTest.js +++ b/test/unit-tests/utils/utilsTest.js @@ -32,9 +32,20 @@ describe('Testing shared utility helpers', () => { expect(result.toISOString()).to.equal('2026-06-04T12:30:00.000Z') }) + it('Should convert valid date-only strings to UTC midnight Date objects', () => { + const result = toDate('2026-06-04') + + expect(result).to.be.instanceOf(Date) + expect(result.toISOString()).to.equal('2026-06-04T00:00:00.000Z') + }) + it('Should return null for invalid timestamp strings', () => { expect(toDate('2026-13-04T12:30:00Z')).to.equal(null) }) + + it('Should return null for invalid date-only strings', () => { + expect(toDate('2026-02-29')).to.equal(null) + }) }) context('isEnrichedContainer', () => { From 4fdd5f79d2d61ecace018c769deb0f63ea17f03d Mon Sep 17 00:00:00 2001 From: Chris Berger Date: Tue, 30 Jun 2026 14:39:08 -0400 Subject: [PATCH 11/19] Import Monday updates data into private conversations as part of Monday migration --- src/scripts/MondayHelpers.js | 45 ++++++++++++++++++++++++++-- src/scripts/MondayMigrate.js | 57 +++++++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/scripts/MondayHelpers.js b/src/scripts/MondayHelpers.js index 4c30b5d76..e6241a4af 100644 --- a/src/scripts/MondayHelpers.js +++ b/src/scripts/MondayHelpers.js @@ -52,15 +52,17 @@ function toSnakeCase (header) { function loadMondayExport (exportPath = mondayExportPath) { const workbook = XLSX.readFile(exportPath) + + // First sheet - org data const worksheet = workbook.Sheets[workbook.SheetNames[0]] const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }) const headerIndex = rows.findIndex( - (row) => String(row[10]).trim() === 'Short Name' + (row) => String(row[11]).trim() === 'Short Name' ) if (headerIndex === -1) { throw new Error( - 'Could not find Monday export header row with column K "Short Name".' + 'Could not find Monday export header row with column L "Short Name".' ) } @@ -89,9 +91,46 @@ function loadMondayExport (exportPath = mondayExportPath) { findDuplicateShortNames(rawRows) + // Second sheet - update (audit) data + const updatesWorksheet = workbook.Sheets[workbook.SheetNames[1]] + const updatesRows = XLSX.utils.sheet_to_json(updatesWorksheet, { header: 1, defval: '' }) + const updatesHeaderIndex = updatesRows.findIndex( + (row) => String(row[1]).trim() === 'Item Name' + ) + + if (updatesHeaderIndex === -1) { + throw new Error( + 'Could not find Monday export header row for updates with column B "Item Name".' + ) + } + + const updatesHeaders = updatesRows[updatesHeaderIndex].map(toSnakeCase) + const partnerNumberIndex = updatesHeaders.indexOf('item_name') + + if (partnerNumberIndex === -1) { + throw new Error('Could not find "item_name" column in Monday export.') + } + + const rawUpdatesRows = updatesRows.slice(updatesHeaderIndex + 1).reduce((result, row) => { + const partnerNumber = String(row[partnerNumberIndex]).trim() + if (!partnerNumber || partnerNumber === 'Item Name') return result + + result.push( + updatesHeaders.reduce((record, header, index) => { + if (header) { + record[header] = index === partnerNumberIndex ? partnerNumber : row[index] + } + return record + }, {}) + ) + + return result + }, []) + return { rawRows, - asOrgRows: buildAsOrgRows(rawRows) + asOrgRows: buildAsOrgRows(rawRows), + rawUpdatesRows } } diff --git a/src/scripts/MondayMigrate.js b/src/scripts/MondayMigrate.js index 630b02baa..37286fcb0 100644 --- a/src/scripts/MondayMigrate.js +++ b/src/scripts/MondayMigrate.js @@ -10,6 +10,7 @@ require('dotenv').config() const { MongoClient } = require('mongodb') const _ = require('lodash') const validator = require('validator') +const uuid = require('uuid') const { loadMondayExport } = require('./MondayHelpers') const dbConnStr = process.env.MONGO_CONN_STRING @@ -80,10 +81,11 @@ async function run () { const mondayExport = loadMondayExport() const unmatchedAsOrgRows = [...mondayExport.asOrgRows] console.log( - `Loaded ${mondayExport.rawRows.length} raw Monday rows and ${mondayExport.asOrgRows.length} converted org rows.` + `Loaded ${mondayExport.rawRows.length} raw Monday rows, ${mondayExport.asOrgRows.length} converted org rows, and ${mondayExport.rawUpdatesRows.length} update rows.` ) await mondayOrgHelper(db, unmatchedAsOrgRows) + await mondayUpdatesHelper(db, mondayExport.rawUpdatesRows) printMondayMigrationStats(unmatchedAsOrgRows) } catch (err) { await dbClient.close() @@ -304,6 +306,59 @@ async function mondayOrgHelper (db, asOrgRows) { } } +async function mondayUpdatesHelper (db, asUpdateRows) { + console.log('Running Monday updates sync...') + const orgCol = await db.collection('BaseOrg') + const conversationCol = await db.collection('Conversation') + const updatesMap = {} + + // Map to partner number + for (const updateRow of asUpdateRows) { + const partnerNumber = String(updateRow.item_name || '').trim().split(' ')[0] + if (!partnerNumber) continue + + if (!updatesMap[partnerNumber]) { + updatesMap[partnerNumber] = [] + } + updatesMap[partnerNumber].push(updateRow) + } + + for (const [partnerNumber, updateRows] of Object.entries(updatesMap)) { + const orgDoc = await orgCol.findOne({ partner_number: partnerNumber }) + if (!orgDoc) continue + + updateRows.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)) + + // Check for existing conversations (should be none on initial migration, but just in case) + const existingConversations = await conversationCol.find({ target_uuid: orgDoc.UUID }).sort({ posted_at: -1 }).toArray() + let previousUUID = existingConversations.length > 0 ? existingConversations[0].UUID : null + const firstNewUUID = uuid.v4() + let newUUID = firstNewUUID + + for (const [idx, updateRow] of updateRows.entries()) { + const conversationDoc = { + UUID: newUUID, + previous_conversation_uuid: previousUUID, + target_uuid: orgDoc.UUID, + author_name: String(updateRow.user || 'Unknown User').trim(), + body: String(updateRow.update_content || '').trim(), + visibility: 'private', + posted_at: new Date(updateRow.created_at) + } + + previousUUID = conversationDoc.UUID + newUUID = idx === updateRows.length - 1 ? null : uuid.v4() + conversationDoc.next_conversation_uuid = newUUID + await conversationCol.insertOne(conversationDoc) + } + + if (existingConversations.length > 0) { + // Link the last existing conversation to the first new one + await conversationCol.updateOne({ UUID: existingConversations[0].UUID }, { $set: { next_conversation_uuid: firstNewUUID } }) + } + } +} + if (require.main === module) { run() } From 4bb974a1376fd3ad7dc0bab20f9cf9e5f43ab250 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 30 Jun 2026 15:52:54 -0400 Subject: [PATCH 12/19] Hardens cve id modification against unauthenticated org short-name fallback --- .../cve-id.controller/cve-id.controller.js | 6 +- .../cve-id/cveIdUpdateTest.js | 14 ++++ test/unit-tests/cve-id/cveIdUpdateTest.js | 69 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/controller/cve-id.controller/cve-id.controller.js b/src/controller/cve-id.controller/cve-id.controller.js index b10497265..8172793ac 100644 --- a/src/controller/cve-id.controller/cve-id.controller.js +++ b/src/controller/cve-id.controller/cve-id.controller.js @@ -341,10 +341,14 @@ async function modifyCveId (req, res, next) { const cveIdRepo = req.ctx.repositories.getCveIdRepository() const userRepo = req.ctx.repositories.getUserRepository() const cveRepo = req.ctx.repositories.getCveRepository() + if (!req.ctx.authenticated) { + return res.status(403).json(error.orgCannotReserveForOther()) + } + const requesterOrgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) const org = requesterOrgUUID && typeof orgRepo.findOneByUUID === 'function' ? await orgRepo.findOneByUUID(requesterOrgUUID) - : (!req.ctx.authenticated ? await orgRepo.findOneByShortName(req.ctx.org) : null) + : null if (!org) { return res.status(403).json(error.orgCannotReserveForOther()) } diff --git a/test/integration-tests/cve-id/cveIdUpdateTest.js b/test/integration-tests/cve-id/cveIdUpdateTest.js index c274fe170..2a1f4c0bd 100644 --- a/test/integration-tests/cve-id/cveIdUpdateTest.js +++ b/test/integration-tests/cve-id/cveIdUpdateTest.js @@ -15,6 +15,20 @@ describe('Text PUT CVE-ID/:id', () => { before(async () => { cveId = await helpers.cveIdReserveHelper(1, '2023', shortName, 'non-sequential') }) + context('Authentication Tests', () => { + it('Endpoint should reject invalid authentication before updating a CVE ID', async () => { + await chai.request(app) + .put(`/api/cve-id/${cveId}?state=REJECTED`) + .set({ + ...constants.nonSecretariatUserHeaders2, + 'CVE-API-Key': 'invalid-api-key' + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(401) + }) + }) + }) context('State parameter Tests', () => { it('Endpoint should return a 400 when state org is set to published', async () => { await chai.request(app) diff --git a/test/unit-tests/cve-id/cveIdUpdateTest.js b/test/unit-tests/cve-id/cveIdUpdateTest.js index 0330bf317..a1c8dbdd4 100644 --- a/test/unit-tests/cve-id/cveIdUpdateTest.js +++ b/test/unit-tests/cve-id/cveIdUpdateTest.js @@ -44,11 +44,21 @@ class UserModifyCveIdOrgAndStateModified { } } +function authenticateAsSecretariat (req) { + req.ctx.authenticated = true + req.ctx.orgUUID = cveIdFixtures.secretariatOrg.UUID + req.ctx.userUUID = cveIdFixtures.secretariatUser.UUID +} + class OrgModifyCveIdOrgAndStateModified { async getOrgUUID () { return cveIdFixtures.org.UUID } + async findOneByUUID () { + return cveIdFixtures.secretariatOrg + } + async findOneByShortName (shortName) { return cveIdFixtures.org } @@ -82,6 +92,53 @@ class CveIdModifyCveIdOrgAndStateModified { describe('Testing the PUT /cve-id/:id endpoint in CveId Controller', () => { context('Negative Tests', () => { + it('Unauthenticated direct controller requests do not resolve the org short name', (done) => { + let shortNameLookupCalled = false + + class OrgModifyCveIdUnauthenticated { + async getOrgUUID () { + return cveIdFixtures.secretariatOrg.UUID + } + + async findOneByUUID () { + return cveIdFixtures.secretariatOrg + } + + async findOneByShortName () { + shortNameLookupCalled = true + return cveIdFixtures.secretariatOrg + } + } + + app.route('/cve-id-modify-unauthenticated/:id') + .put((req, res, next) => { + const factory = { + getCveIdRepository: () => { return new CveIdModifyCveIdOrgAndStateModified() }, + getOrgRepository: () => { return new OrgModifyCveIdUnauthenticated() }, + getUserRepository: () => { return new NullUserRepo() }, + getCveRepository: () => { return new NullCveRepo() } + } + req.ctx.repositories = factory + next() + }, cveIdParams.parsePostParams, cveIdController.CVEID_UPDATE_SINGLE) + + chai.request(app) + .put(`/cve-id-modify-unauthenticated/${cveIdFixtures.cveId}?state=REJECTED`) + .set(cveIdFixtures.secretariatHeader) + .end((err, res) => { + if (err) { + done(err) + } + + expect(res).to.have.status(403) + expect(shortNameLookupCalled).to.equal(false) + const errObj = error.orgCannotReserveForOther() + expect(res.body.error).to.equal(errObj.error) + expect(res.body.message).to.equal(errObj.message) + done() + }) + }) + it('CVE ID does not exist', (done) => { class CveIdModifyCveIdDoesntExist { async findOneByCveId () { @@ -99,6 +156,10 @@ describe('Testing the PUT /cve-id/:id endpoint in CveId Controller', () => { return null } + async findOneByUUID () { + return cveIdFixtures.secretariatOrg + } + async findOneByShortName (shortName) { return cveIdFixtures.org } @@ -112,6 +173,7 @@ describe('Testing the PUT /cve-id/:id endpoint in CveId Controller', () => { getUserRepository: () => { return new NullUserRepo() }, getCveRepository: () => { return new NullCveRepo() } } + authenticateAsSecretariat(req) req.ctx.repositories = factory next() }, cveIdParams.parsePostParams, cveIdController.CVEID_UPDATE_SINGLE) @@ -139,6 +201,10 @@ describe('Testing the PUT /cve-id/:id endpoint in CveId Controller', () => { return null } + async findOneByUUID () { + return cveIdFixtures.secretariatOrg + } + async findOneByShortName (shortName) { return cveIdFixtures.org } @@ -152,6 +218,7 @@ describe('Testing the PUT /cve-id/:id endpoint in CveId Controller', () => { getUserRepository: () => { return new NullUserRepo() }, getCveRepository: () => { return new NullCveRepo() } } + authenticateAsSecretariat(req) req.ctx.repositories = factory next() }, cveIdParams.parsePostParams, cveIdController.CVEID_UPDATE_SINGLE) @@ -219,6 +286,7 @@ describe('Testing the PUT /cve-id/:id endpoint in CveId Controller', () => { getUserRepository: () => { return new UserModifyCveIdOrgAndStateModified() }, getCveRepository: () => { return new NullCveRepo() } } + authenticateAsSecretariat(req) req.ctx.repositories = factory next() }, cveIdParams.parsePostParams, cveIdController.CVEID_UPDATE_SINGLE) @@ -279,6 +347,7 @@ describe('Testing the PUT /cve-id/:id endpoint in CveId Controller', () => { getUserRepository: () => { return new UserModifyCveIdOrgAndStateModified() }, getCveRepository: () => { return new NullCveRepo() } } + authenticateAsSecretariat(req) req.ctx.repositories = factory next() }, cveIdParams.parsePostParams, cveIdController.CVEID_UPDATE_SINGLE) From 9510805f3de430fa1a2d94cc7e44e89f5978088e Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 30 Jun 2026 16:03:37 -0400 Subject: [PATCH 13/19] limits CVE-API-USER header length --- src/constants/index.js | 1 + src/middleware/middleware.js | 7 ++++++- .../middleware/authenticatedContextTest.js | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/constants/index.js b/src/constants/index.js index 2869c831b..4a6c1d38b 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -106,6 +106,7 @@ function getConstants () { }, MAX_SHORTNAME_LENGTH: 32, MIN_SHORTNAME_LENGTH: 2, + MAX_USERNAME_LENGTH: 128, MAX_FIRSTNAME_LENGTH: 100, MAX_LASTNAME_LENGTH: 100, MAX_MIDDLENAME_LENGTH: 100, diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index 7682b2672..3cbadf219 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -24,12 +24,17 @@ function createCtxAndReqUUID (req, res, next) { const CONSTANTS = getConstants() try { + const user = req.header(CONSTANTS.AUTH_HEADERS.USER) + if (user && user.length > CONSTANTS.MAX_USERNAME_LENGTH) { + return res.status(400).json(error.genericBadRequest(`${CONSTANTS.AUTH_HEADERS.USER} header field cannot exceed ${CONSTANTS.MAX_USERNAME_LENGTH} characters.`)) + } + req.ctx = { authenticated: false, uuid: uuid.v4(), org: req.header(CONSTANTS.AUTH_HEADERS.ORG), orgUUID: null, - user: req.header(CONSTANTS.AUTH_HEADERS.USER), + user: user, userUUID: null, key: req.header(CONSTANTS.AUTH_HEADERS.KEY), repositories: new RepositoryFactory() diff --git a/test/integration-tests/middleware/authenticatedContextTest.js b/test/integration-tests/middleware/authenticatedContextTest.js index eb4ec1779..6ad751346 100644 --- a/test/integration-tests/middleware/authenticatedContextTest.js +++ b/test/integration-tests/middleware/authenticatedContextTest.js @@ -17,6 +17,23 @@ const CveId = require('../../../src/model/cve-id') const CONSTANTS = getConstants() describe('Authenticated request context middleware integration', () => { + it('rejects CVE-API-USER values longer than the valid username length before authentication', async () => { + const headers = { + 'content-type': 'application/json', + [CONSTANTS.AUTH_HEADERS.ORG]: mwFixtures.existentOrg.short_name, + [CONSTANTS.AUTH_HEADERS.USER]: 'a'.repeat(CONSTANTS.MAX_USERNAME_LENGTH + 1), + [CONSTANTS.AUTH_HEADERS.KEY]: mwFixtures.secretariatHeaders[CONSTANTS.AUTH_HEADERS.KEY] + } + + const res = await chai.request(serviceApp) + .get('/api/cve-id') + .set(headers) + + expect(res).to.have.status(400) + expect(res.body.error).to.equal('BAD_REQUEST') + expect(res.body.message).to.equal(`${CONSTANTS.AUTH_HEADERS.USER} header field cannot exceed ${CONSTANTS.MAX_USERNAME_LENGTH} characters.`) + }) + it('uses the authenticated org UUID for Secretariat authorization after key validation', async () => { const app = express() const authenticatedOrg = { From 99b21e5b00cf4f2bf53816e4a81fec722b16b9ca Mon Sep 17 00:00:00 2001 From: david-rocca Date: Tue, 30 Jun 2026 16:20:55 -0400 Subject: [PATCH 14/19] hardens auth context helpers so unauthenticated requests cannot use unvalidated requester header context --- src/middleware/middleware.js | 3 + src/utils/authContext.js | 30 ++++- test/integration-tests/cve-id/getCveIdTest.js | 19 ++++ test/unit-tests/utils/authContextTest.js | 104 ++++++++++++++++++ 4 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 test/unit-tests/utils/authContextTest.js diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index 3cbadf219..aa32a66b6 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -31,6 +31,7 @@ function createCtxAndReqUUID (req, res, next) { req.ctx = { authenticated: false, + authenticationChecked: false, uuid: uuid.v4(), org: req.header(CONSTANTS.AUTH_HEADERS.ORG), orgUUID: null, @@ -84,6 +85,7 @@ async function optionallyValidateUser (req, res, next) { } req.ctx.authenticated = authenticated + req.ctx.authenticationChecked = true if (authenticated) { logger.info({ uuid: req.ctx.uuid, message: 'SUCCESSFUL user authentication for ' + user }) } @@ -158,6 +160,7 @@ async function validateUser (req, res, next) { req.ctx.orgUUID = orgUUID req.ctx.userUUID = result.UUID req.ctx.authenticated = true + req.ctx.authenticationChecked = true logger.info({ uuid: req.ctx.uuid, message: 'SUCCESSFUL user authentication for ' + user }) next() } catch (err) { diff --git a/src/utils/authContext.js b/src/utils/authContext.js index 9517eac02..17d5f2799 100644 --- a/src/utils/authContext.js +++ b/src/utils/authContext.js @@ -48,6 +48,10 @@ function isAuthenticatedRequest (req) { return req?.ctx?.authenticated === true } +function isUnauthenticatedAfterAuthenticationCheck (req) { + return !isAuthenticatedRequest(req) && req?.ctx?.authenticationChecked === true +} + async function findOrgByUUID (orgRepo, orgUUID, options = {}, returnLegacyFormat = false) { if (!orgUUID || typeof orgRepo?.findOneByUUID !== 'function') { return null @@ -152,7 +156,7 @@ async function getRequesterOrgUUID (req, orgRepo, options = {}, useLegacy = fals return req.ctx.orgUUID } - if (isAuthenticatedRequest(req)) { + if (isAuthenticatedRequest(req) || isUnauthenticatedAfterAuthenticationCheck(req)) { return null } @@ -173,7 +177,7 @@ async function getRequesterOrg (req, orgRepo, options = {}, returnLegacyFormat = return findOrgByUUID(orgRepo, req.ctx.orgUUID, options, returnLegacyFormat) } - if (isAuthenticatedRequest(req)) { + if (isAuthenticatedRequest(req) || isUnauthenticatedAfterAuthenticationCheck(req)) { return null } @@ -189,7 +193,7 @@ async function getRequesterUser (req, userRepo, orgRepo, options = {}, isRegistr return findUserByUUID(userRepo, req.ctx.userUUID, options, isRegistryObject) } - if (isAuthenticatedRequest(req) && !req.ctx.userUUID) { + if (isAuthenticatedRequest(req) || isUnauthenticatedAfterAuthenticationCheck(req)) { return null } @@ -215,7 +219,7 @@ async function getRequesterUserUUID (req, userRepo, orgRepo, options = {}, isReg return req.ctx.userUUID } - if (isAuthenticatedRequest(req)) { + if (isAuthenticatedRequest(req) || isUnauthenticatedAfterAuthenticationCheck(req)) { return null } @@ -281,6 +285,10 @@ async function isRequesterSecretariat (req, orgRepo, options = {}, returnLegacyF return orgHasRoleByUUID(orgRepo, orgUUID, 'SECRETARIAT', options, returnLegacyFormat) } + if (isUnauthenticatedAfterAuthenticationCheck(req)) { + return false + } + if (hasOwnMethod(orgRepo, 'isSecretariatByShortName')) { return orgRepo.isSecretariatByShortName(req.ctx.org, options, returnLegacyFormat) } @@ -312,6 +320,10 @@ async function isRequesterBulkDownload (req, orgRepo, options = {}, returnLegacy return orgHasRoleByUUID(orgRepo, orgUUID, 'BULK_DOWNLOAD', options, returnLegacyFormat) } + if (isUnauthenticatedAfterAuthenticationCheck(req)) { + return false + } + if (typeof orgRepo?.isBulkDownloadByShortname === 'function') { return orgRepo.isBulkDownloadByShortname(req.ctx.org, options, returnLegacyFormat) } @@ -332,6 +344,10 @@ async function isRequesterAdmin (req, userRepo, orgRepo, options = {}, isRegistr return isUserAdminOfOrgUUID(userRepo, orgRepo, req.ctx.userUUID, req.ctx.orgUUID, options, isRegistryObject) } + if (isUnauthenticatedAfterAuthenticationCheck(req)) { + return false + } + if (typeof userRepo?.isAdmin === 'function') { return userRepo.isAdmin(req.ctx.user, req.ctx.org, options, isRegistryObject) } @@ -344,7 +360,7 @@ async function isRequesterAdminOfOrg (req, userRepo, orgRepo, targetOrgOrShortNa ? targetOrgOrShortName : targetOrgOrShortName?.short_name - if (isAuthenticatedRequest(req)) { + if (isAuthenticatedRequest(req) || (req.ctx.orgUUID && req.ctx.userUUID)) { if (!req.ctx.orgUUID || !req.ctx.userUUID) { return false } @@ -379,6 +395,10 @@ async function isRequesterAdminOfOrg (req, userRepo, orgRepo, targetOrgOrShortNa return false } + if (isUnauthenticatedAfterAuthenticationCheck(req)) { + return false + } + if (!req.ctx.orgUUID && !req.ctx.userUUID) { if (hasOwnMethod(userRepo, 'isAdminOrSecretariat')) { return userRepo.isAdminOrSecretariat(fallbackTargetShortName, req.ctx.user, req.ctx.org, options, isRegistryObject) diff --git a/test/integration-tests/cve-id/getCveIdTest.js b/test/integration-tests/cve-id/getCveIdTest.js index 0da515155..053b9b84f 100644 --- a/test/integration-tests/cve-id/getCveIdTest.js +++ b/test/integration-tests/cve-id/getCveIdTest.js @@ -267,6 +267,25 @@ describe('Testing Get CVE-ID endpoint', () => { }) }) + it('Invalid Secretariat credentials should be treated as unauthenticated for optionallyValidateUser endpoints (GET /api/cve-id/:id)', async function () { + const cveId = await helpers.cveIdReserveHelper(1, '2023', constants.nonSecretariatUserHeaders['CVE-API-ORG'], 'non-sequential') + const invalidSecretariatHeaders = { + ...constants.headers, + 'CVE-API-KEY': 'invalid-secret' + } + + await chai.request(app) + .get(`/api/cve-id/${cveId}`) + .set(invalidSecretariatHeaders) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body.cve_id).to.equal(cveId) + expect(res.body.owning_cna).to.equal('[REDACTED]') + expect(res.body).to.not.have.property('requested_by') + }) + }) + it('An inactive user should be denied access for validateUser endpoints (GET /api/cve-id)', async function () { // Deactivate user await helpers.userDeactivateAsSecHelper(constants.nonSecretariatUserHeaders['CVE-API-USER'], constants.nonSecretariatUserHeaders['CVE-API-ORG']) diff --git a/test/unit-tests/utils/authContextTest.js b/test/unit-tests/utils/authContextTest.js new file mode 100644 index 000000000..e59d13863 --- /dev/null +++ b/test/unit-tests/utils/authContextTest.js @@ -0,0 +1,104 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const authContext = require('../../../src/utils/authContext') + +describe('Testing authContext requester helpers', () => { + let req + let orgRepo + let userRepo + + beforeEach(() => { + req = { + ctx: { + authenticated: false, + authenticationChecked: true, + org: 'mitre', + orgUUID: null, + user: 'admin-user', + userUUID: null + } + } + + orgRepo = { + getOrgUUID: sinon.stub().rejects(new Error('short-name org lookup should not be called')), + findOneByShortName: sinon.stub().rejects(new Error('short-name org lookup should not be called')), + isSecretariatByShortName: sinon.stub().rejects(new Error('short-name Secretariat check should not be called')), + isBulkDownloadByShortname: sinon.stub().rejects(new Error('short-name bulk download check should not be called')), + isSecretariat: sinon.stub().throws(new Error('short-name Secretariat check should not be called')), + isBulkDownload: sinon.stub().throws(new Error('short-name bulk download check should not be called')), + hasRoleByUUID: sinon.stub().resolves(true), + findOneByUUID: sinon.stub().resolves({ UUID: 'org-uuid', authority: ['SECRETARIAT'] }) + } + + userRepo = { + getUserUUID: sinon.stub().rejects(new Error('short-name user lookup should not be called')), + findOneByUsernameAndOrgShortname: sinon.stub().rejects(new Error('short-name user lookup should not be called')), + isAdmin: sinon.stub().rejects(new Error('short-name admin check should not be called')), + isAdminOrSecretariat: sinon.stub().rejects(new Error('short-name admin check should not be called')), + isUserAdminOfOrgUUID: sinon.stub().resolves(true), + findUserByUUID: sinon.stub().resolves({ UUID: 'user-uuid', role: 'ADMIN' }) + } + }) + + it('Should not resolve requester identity or roles from unauthenticated header values', async () => { + expect(await authContext.getRequesterOrgUUID(req, orgRepo)).to.equal(null) + expect(await authContext.getRequesterOrg(req, orgRepo)).to.equal(null) + expect(await authContext.getRequesterUser(req, userRepo, orgRepo)).to.equal(null) + expect(await authContext.getRequesterUserUUID(req, userRepo, orgRepo)).to.equal(null) + expect(await authContext.isRequesterSecretariat(req, orgRepo)).to.equal(false) + expect(await authContext.isRequesterBulkDownload(req, orgRepo)).to.equal(false) + expect(await authContext.isRequesterAdmin(req, userRepo, orgRepo)).to.equal(false) + expect(await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, 'target-org')).to.equal(false) + + const context = await authContext.getRequesterContext(req, { orgRepo, userRepo }) + expect(context).to.deep.equal({ + org: null, + orgUUID: null, + user: null, + userUUID: null, + isSecretariat: false, + isBulkDownload: false, + isAdmin: false + }) + + expect(orgRepo.getOrgUUID.called).to.equal(false) + expect(orgRepo.findOneByShortName.called).to.equal(false) + expect(orgRepo.isSecretariatByShortName.called).to.equal(false) + expect(orgRepo.isBulkDownloadByShortname.called).to.equal(false) + expect(userRepo.getUserUUID.called).to.equal(false) + expect(userRepo.findOneByUsernameAndOrgShortname.called).to.equal(false) + expect(userRepo.isAdmin.called).to.equal(false) + expect(userRepo.isAdminOrSecretariat.called).to.equal(false) + }) + + it('Should resolve requester identity and roles from internal UUID context without short-name lookups', async () => { + req.ctx.orgUUID = 'org-uuid' + req.ctx.userUUID = 'user-uuid' + + expect(await authContext.getRequesterOrgUUID(req, orgRepo)).to.equal('org-uuid') + expect(await authContext.getRequesterUserUUID(req, userRepo, orgRepo)).to.equal('user-uuid') + expect(await authContext.isRequesterSecretariat(req, orgRepo)).to.equal(true) + expect(await authContext.isRequesterAdmin(req, userRepo, orgRepo)).to.equal(true) + + expect(orgRepo.getOrgUUID.called).to.equal(false) + expect(orgRepo.findOneByShortName.called).to.equal(false) + expect(userRepo.getUserUUID.called).to.equal(false) + expect(userRepo.isAdmin.called).to.equal(false) + }) + + it('Should resolve requester identity and roles from authenticated UUID context', async () => { + req.ctx.authenticated = true + req.ctx.orgUUID = 'org-uuid' + req.ctx.userUUID = 'user-uuid' + + expect(await authContext.getRequesterOrgUUID(req, orgRepo)).to.equal('org-uuid') + expect(await authContext.getRequesterOrg(req, orgRepo)).to.deep.equal({ UUID: 'org-uuid', authority: ['SECRETARIAT'] }) + expect(await authContext.getRequesterUserUUID(req, userRepo, orgRepo)).to.equal('user-uuid') + expect(await authContext.getRequesterUser(req, userRepo, orgRepo)).to.deep.equal({ UUID: 'user-uuid', role: 'ADMIN' }) + expect(await authContext.isRequesterSecretariat(req, orgRepo)).to.equal(true) + expect(await authContext.isRequesterBulkDownload(req, orgRepo)).to.equal(true) + expect(await authContext.isRequesterAdmin(req, userRepo, orgRepo)).to.equal(true) + expect(await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, { UUID: 'target-org-uuid', admins: ['user-uuid'] })).to.equal(true) + }) +}) From 2ed29e0dc62d8e67f13a9526835dabd5749e79a7 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Wed, 1 Jul 2026 14:13:57 -0400 Subject: [PATCH 15/19] Updating some documentation --- api-docs/openapi.json | 96 +++++++++---------- src/controller/audit.controller/index.js | 2 +- .../conversation.controller/index.js | 9 +- src/controller/cve-id.controller/index.js | 5 +- src/controller/cve.controller/index.js | 12 +-- src/controller/org.controller/index.js | 71 +++++++------- .../registry-org.controller/index.js | 16 ++-- .../registry-org.controller.js | 9 +- .../registry-user.controller/index.js | 6 +- .../registry-user.controller.js | 2 +- .../review-object.controller/index.js | 12 +-- src/swagger.js | 2 +- 12 files changed, 119 insertions(+), 123 deletions(-) diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 2eb805748..dc339d98a 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -20,8 +20,8 @@ "tags": [ "CVE ID" ], - "summary": "Retrieves information about CVE IDs after applying the query parameters as filters (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular, CNA & Admin Users: Retrieves filtered CVE IDs owned by the user's organization

Secretariat: Retrieves filtered CVE IDs owned by any organization

", + "summary": "Retrieves information about CVE IDs after applying the query parameters as filters (accessible to registered users, Secretariat, and Bulk Download)", + "description": "

Access Control

Registered users can access this endpoint. Secretariat and Bulk Download organizations can retrieve CVE IDs across organizations; other users are limited to CVE IDs owned by their own organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves filtered CVE IDs owned by the user's organization

Secretariat: Retrieves filtered CVE IDs owned by any organization

Bulk Download: Retrieves filtered CVE IDs owned by any organization with owner and requester details redacted

", "operationId": "cveIdGetFiltered", "parameters": [ { @@ -1094,8 +1094,8 @@ "tags": [ "CVE Record" ], - "summary": "Retrieves all CVE Records after applying the query parameters as filters (accessible to Secretariat)", - "description": "

Access Control

User must belong to an organization with the Secretariat role

Expected Behavior

Secretariat: Retrieves all CVE records for all organizations

", + "summary": "Retrieves all CVE Records after applying the query parameters as filters (accessible to Secretariat or Bulk Download)", + "description": "

Access Control

User must belong to an organization with the Secretariat or Bulk Download role

Expected Behavior

Secretariat and Bulk Download: Retrieves all CVE records for all organizations

", "operationId": "cveGetFiltered", "parameters": [ { @@ -1258,8 +1258,8 @@ "tags": [ "CVE Record" ], - "summary": "Retrieves all CVE Records after applying the query parameters as filters. Uses cursor pagination to paginate results (accessible to Secretariat)", - "description": "

Access Control

User must belong to an organization with the Secretariat role

Expected Behavior

Secretariat: Retrieves all CVE records for all organizations

", + "summary": "Retrieves all CVE Records after applying the query parameters as filters. Uses cursor pagination to paginate results (accessible to Secretariat or Bulk Download)", + "description": "

Access Control

User must belong to an organization with the Secretariat or Bulk Download role

Expected Behavior

Secretariat and Bulk Download: Retrieves all CVE records for all organizations

", "operationId": "cveGetFilteredCursor", "parameters": [ { @@ -2096,8 +2096,8 @@ "tags": [ "Registry User" ], - "summary": "Retrieves all users for the organization with the specified short name (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about users in the same organization

Secretariat: Retrieves all user information for any organization

", + "summary": "Retrieves all users for the organization with the specified short name (accessible to same-organization users or Secretariat)", + "description": "

Access Control

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about users in the same organization

Secretariat: Retrieves all user information for any organization

", "operationId": "userOrgAll", "parameters": [ { @@ -2213,8 +2213,8 @@ "tags": [ "Registry Organization" ], - "summary": "Retrieves an organization's CVE ID quota (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular, CNA & Admin Users: Retrieves the CVE ID quota for the user's organization

Secretariat: Retrieves the CVE ID quota for any organization

", + "summary": "Retrieves an organization's CVE ID quota (accessible to same-organization users or Secretariat)", + "description": "

Access Control

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves the CVE ID quota for the user's organization

Secretariat: Retrieves the CVE ID quota for any organization

", "operationId": "orgIdQuota", "parameters": [ { @@ -2305,8 +2305,8 @@ "tags": [ "Registry Organization" ], - "summary": "Retrieves information about the registry organization specified by short name or UUID (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

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

Secretariat: Retrieves information about any registry organization

", + "summary": "Retrieves information about the registry organization specified by short name or UUID (accessible to same-organization users or Secretariat)", + "description": "

Access Control

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

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

Secretariat: Retrieves information about any registry organization

", "operationId": "registryOrgSingle", "parameters": [ { @@ -2421,8 +2421,8 @@ "tags": [ "Registry User" ], - "summary": "Retrieves information about a user for the specified username and organization short name (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about a registry user in the same organization

Secretariat: Retrieves any registry user's information

", + "summary": "Retrieves information about a user for the specified username and organization short name (accessible to same-organization users or Secretariat)", + "description": "

Access Control

Authenticated users can access this endpoint only for users in their own organization. Secretariat users can access any user.

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about a registry user in the same organization

Secretariat: Retrieves any registry user's information

", "operationId": "registryUserSingle", "parameters": [ { @@ -2518,8 +2518,8 @@ "tags": [ "Registry User" ], - "summary": "Updates information about a user for the specified username and organization shortname (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular User: Updates the user's own information. Only name fields may be changed.

Admin User: Updates information about a user in the Admin's organization. Allowed to change all fields except org_short_name.

Secretariat: Updates information about a user in any organization. Allowed to change all fields.

", + "summary": "Updates information about a user for the specified username and organization shortname (accessible to self, same-organization Admins, or Secretariat)", + "description": "

Access Control

Authenticated users can update their own name fields. Organization admins can update users in their organization. Secretariat users can update users in any organization.

Expected Behavior

Regular User: Updates the user's own information. Only name fields may be changed.

Admin User: Updates information about a user in the Admin's organization. Allowed to change all fields except org_short_name.

Secretariat: Updates information about a user in any organization. Allowed to change all fields.

", "operationId": "registryUserUpdateSingle", "parameters": [ { @@ -2639,8 +2639,8 @@ "tags": [ "Registry Organization" ], - "summary": "Updates information about the organization specified by short name (accessible Temporarily to Secretariat only)", - "description": "

Access Control

User must belong to an organization with the Secretariat role temporarily.

In the future, only the organization's admin will be able to request changes to its information.

With Joint Approval required for the following fields:

Expected Behavior

This endpoint expects a full organization object in the request body.

Secretariat: Updates any organization's information

Organization Admin: Requests changes to its organization's information

  • short_name
  • long_name
  • authority
  • aliases
  • oversees
  • top_level_root
  • charter_or_scope
  • product_list
  • disclosure_policy
  • contact_info.websites
  • contact_info.emails
  • contact_info.phone
  • partner_role_type
  • partner_country
  • advisory_locations
  • advisory_location_require_credentials
  • vulnerability_advisory_location_for_web_scraping
  • industry
  • tl_root_start_date
  • is_cna_discussion_list
", + "summary": "Updates information about the organization specified by short name (accessible to Secretariat or same-organization Admin)", + "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the requested organization.

With Joint Approval required for the following fields:

Expected Behavior

This endpoint expects a full organization object in the request body.

Secretariat: Updates any organization's information

Organization Admin: Requests changes to its organization's information

  • short_name
  • long_name
  • authority
  • aliases
  • oversees
  • top_level_root
  • charter_or_scope
  • product_list
  • disclosure_policy
  • contact_info.websites
  • contact_info.emails
  • contact_info.phone
  • partner_role_type
  • partner_country
  • advisory_locations
  • advisory_location_require_credentials
  • vulnerability_advisory_location_for_web_scraping
  • industry
  • tl_root_start_date
  • is_cna_discussion_list
", "operationId": "orgUpdateSingle", "parameters": [ { @@ -2749,8 +2749,8 @@ "tags": [ "Registry User" ], - "summary": "Create a user with the provided short name as the owning organization (accessible to Admins and Secretariats)", - "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the organization

Expected Behavior

Admin User: Creates a user for the Admin's organization

Secretariat: Creates a user for any organization

", + "summary": "Create a user with the provided short name as the owning organization (accessible to Secretariat or target organization Admin)", + "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Admin User: Creates a user for the Admin's organization

Secretariat: Creates a user for any organization

", "operationId": "registryUserCreateSingle", "parameters": [ { @@ -2873,8 +2873,8 @@ "tags": [ "Registry User" ], - "summary": "Reset the API key for a user (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular User: Resets user's own API secret

Admin User: Resets any user's API secret in the Admin's organization

Secretariat: Resets any user's API secret

", + "summary": "Reset the API key for a user (accessible to self, same-organization Admins, or Secretariat)", + "description": "

Access Control

Authenticated users can reset their own API secret. Organization admins can reset users in their organization. Secretariat users can reset any user's API secret.

Expected Behavior

Regular User: Resets user's own API secret

Admin User: Resets any user's API secret in the Admin's organization

Secretariat: Resets any user's API secret

", "operationId": "userResetSecret", "parameters": [ { @@ -2975,7 +2975,7 @@ "Registry User" ], "summary": "Grants a role to a user (accessible to Secretariat or Org Admin)", - "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the organization

Expected Behavior

Admin User: Grants a role to a user in the Admin's organization

Secretariat: Grants a role to a user in any organization

", + "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Admin User: Grants a role to a user in the Admin's organization

Secretariat: Grants a role to a user in any organization

", "operationId": "registryUserGrantRole", "parameters": [ { @@ -3102,7 +3102,7 @@ "Registry User" ], "summary": "Revokes a role from a user (accessible to Secretariat or Org Admin)", - "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the organization

Expected Behavior

Admin User: Revokes a role from a user in the Admin's organization

Secretariat: Revokes a role from a user in any organization

", + "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Admin User: Revokes a role from a user in the Admin's organization

Secretariat: Revokes a role from a user in any organization

", "operationId": "registryUserRevokeRole", "parameters": [ { @@ -3228,8 +3228,8 @@ "tags": [ "Registry Organization" ], - "summary": "Update the conversation at the given index for the given organization (accessible to Secretariat or Org Admin)", - "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the organization

Expected Behavior

Admin User: Allowed to update only the message body of any conversation posted by them

Secretariat: Allowed to update the message body and/or visibility of any conversation

", + "summary": "Update the conversation at the given index for the given organization (accessible to Secretariat or original same-organization author)", + "description": "

Access Control

User must belong to an organization with the Secretariat role or be the original author of the conversation in the same organization

Expected Behavior

Original Author: Allowed to update only the message body of a conversation posted by them

Secretariat: Allowed to update the message body and/or visibility of any conversation

", "operationId": "registryUserUpdateConversation", "parameters": [ { @@ -3527,8 +3527,8 @@ "tags": [ "Organization" ], - "summary": "Retrieves information about the organization specified by short name or UUID (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

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

Secretariat: Retrieves information about any organization

", + "summary": "Retrieves information about the organization specified by short name or UUID (accessible to same-organization users or Secretariat)", + "description": "

Access Control

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

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

Secretariat: Retrieves information about any organization

", "operationId": "orgSingle", "parameters": [ { @@ -3733,8 +3733,8 @@ "tags": [ "Organization" ], - "summary": "Retrieves an organization's CVE ID quota (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular, CNA & Admin Users: Retrieves the CVE ID quota for the user's organization

Secretariat: Retrieves the CVE ID quota for any organization

", + "summary": "Retrieves an organization's CVE ID quota (accessible to same-organization users or Secretariat)", + "description": "

Access Control

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves the CVE ID quota for the user's organization

Secretariat: Retrieves the CVE ID quota for any organization

", "operationId": "orgIdQuota", "parameters": [ { @@ -3825,8 +3825,8 @@ "tags": [ "Users" ], - "summary": "Retrieves all users for the organization with the specified short name (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about users in the same organization

Secretariat: Retrieves all user information for any organization

", + "summary": "Retrieves all users for the organization with the specified short name (accessible to same-organization users or Secretariat)", + "description": "

Access Control

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about users in the same organization

Secretariat: Retrieves all user information for any organization

", "operationId": "userOrgAll", "parameters": [ { @@ -3927,8 +3927,8 @@ "tags": [ "Users" ], - "summary": "Create a user with the provided short name as the owning organization (accessible to Admins and Secretariats)", - "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the organization

Expected Behavior

Admin User: Creates a user for the Admin's organization

Secretariat: Creates a user for any organization

", + "summary": "Create a user with the provided short name as the owning organization (accessible to Secretariat or target organization Admin)", + "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Admin User: Creates a user for the Admin's organization

Secretariat: Creates a user for any organization

", "operationId": "userCreateSingle", "parameters": [ { @@ -4029,8 +4029,8 @@ "tags": [ "Users" ], - "summary": "Retrieves information about a user for the specified username and organization short name (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about a user in the same organization

Secretariat: Retrieves any user's information

", + "summary": "Retrieves information about a user for the specified username and organization short name (accessible to same-organization users or Secretariat)", + "description": "

Access Control

Authenticated users can access this endpoint only for users in their own organization. Secretariat users can access any user.

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about a user in the same organization

Secretariat: Retrieves any user's information

", "operationId": "userSingle", "parameters": [ { @@ -4128,8 +4128,8 @@ "tags": [ "Users" ], - "summary": "Updates information about a user for the specified username and organization shortname (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular User: Updates the user's own information. Only name fields may be changed.

Admin User: Updates information about a user in the Admin's organization. Allowed to change all fields except org_short_name.

Secretariat: Updates information about a user in any organization. Allowed to change all fields.

", + "summary": "Updates information about a user for the specified username and organization shortname (accessible to self, same-organization Admins, or Secretariat)", + "description": "

Access Control

Authenticated users can update their own name fields. Organization admins can update users in their organization. Secretariat users can update users in any organization.

Expected Behavior

Regular User: Updates the user's own information. Only name fields may be changed.

Admin User: Updates information about a user in the Admin's organization. Allowed to change all fields except org_short_name.

Secretariat: Updates information about a user in any organization. Allowed to change all fields.

", "operationId": "userUpdateSingle", "parameters": [ { @@ -4256,8 +4256,8 @@ "tags": [ "Users" ], - "summary": "Reset the API key for a user (accessible to all registered users)", - "description": "

Access Control

All registered users can access this endpoint

Expected Behavior

Regular User: Resets user's own API secret

Admin User: Resets any user's API secret in the Admin's organization

Secretariat: Resets any user's API secret

", + "summary": "Reset the API key for a user (accessible to self, same-organization Admins, or Secretariat)", + "description": "

Access Control

Authenticated users can reset their own API secret. Organization admins can reset users in their organization. Secretariat users can reset any user's API secret.

Expected Behavior

Regular User: Resets user's own API secret

Admin User: Resets any user's API secret in the Admin's organization

Secretariat: Resets any user's API secret

", "operationId": "userResetSecret", "parameters": [ { @@ -4782,8 +4782,8 @@ "tags": [ "Conversation" ], - "summary": "Creates a conversation for a specific target UUID (accessible to Secretariat only)", - "description": "

Access Control

User must belong to an organization with the Secretariat role

Expected Behavior

Secretariat: Creates a conversation for the specified target UUID

", + "summary": "Creates a conversation for a specific target UUID (accessible to Secretariat or target organization Admin)", + "description": "

Access Control

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Secretariat: Creates a conversation for the specified target UUID

Organization Admin: Creates a conversation only when the target UUID is the admin's organization UUID

", "operationId": "createConversationForTargetUUID", "parameters": [ { @@ -5034,8 +5034,8 @@ "tags": [ "Review Object" ], - "summary": "Retrieves a review object by its UUID (accessible to Secretariat or Admin)", - "description": "

Access Control

User must belong to an organization with the Secretariat role or have the Admin role

", + "summary": "Retrieves a review object by its UUID (accessible to Secretariat or same-organization Admin)", + "description": "

Access Control

User must belong to an organization with the Secretariat role or have the Admin role for the review object's target organization

", "operationId": "getReviewObjectByUUID", "parameters": [ { @@ -5136,8 +5136,8 @@ "tags": [ "Review Object" ], - "summary": "Retrieves the PENDING review object for an organization (accessible to Secretariat only)", - "description": "

Access Control

User must belong to an organization with the Secretariat role

", + "summary": "Retrieves the PENDING review object for an organization (accessible to Secretariat or same-organization Admin)", + "description": "

Access Control

User must belong to an organization with the Secretariat role or have the Admin role for the requested organization

", "operationId": "getReviewObjectByOrgIdentifier", "parameters": [ { @@ -5357,8 +5357,8 @@ "tags": [ "Review Object" ], - "summary": "Retrieves the review history for an organization (accessible to Secretariat or Admin)", - "description": "

Access Control

User must belong to an organization with the Secretariat role or have the Admin role

", + "summary": "Retrieves the review history for an organization (accessible to Secretariat or same-organization Admin)", + "description": "

Access Control

User must belong to an organization with the Secretariat role or have the Admin role for the requested organization

", "operationId": "getReviewHistoryByOrgShortNamePaginated", "parameters": [ { diff --git a/src/controller/audit.controller/index.js b/src/controller/audit.controller/index.js index e3b7bb16f..67e8b804b 100644 --- a/src/controller/audit.controller/index.js +++ b/src/controller/audit.controller/index.js @@ -216,7 +216,7 @@ router.get('/audit/org/document/:document_uuid', controller.AUDIT_GET_BY_UUID ) -// Get audit by org identifier (Secretariat or Admin) +// Get audit by org identifier (Secretariat only) router.get('/audit/org/:org_identifier', /* #swagger.tags = ['Audit'] diff --git a/src/controller/conversation.controller/index.js b/src/controller/conversation.controller/index.js index 1a5966389..25674279f 100644 --- a/src/controller/conversation.controller/index.js +++ b/src/controller/conversation.controller/index.js @@ -207,17 +207,18 @@ router.get('/conversation/target/:uuid', controller.getConversationsForTargetUUID ) -// Post conversation for target UUID - SEC only +// Post conversation for target UUID - Secretariat or target org admin router.post('/conversation/target/:uuid', /* #swagger.tags = ['Conversation'] #swagger.operationId = 'createConversationForTargetUUID' - #swagger.summary = "Creates a conversation for a specific target UUID (accessible to Secretariat only)" + #swagger.summary = "Creates a conversation for a specific target UUID (accessible to Secretariat or target organization Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role

+

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

-

Secretariat: Creates a conversation for the specified target UUID

" +

Secretariat: Creates a conversation for the specified target UUID

+

Organization Admin: Creates a conversation only when the target UUID is the admin's organization UUID

" #swagger.parameters['uuid'] = { description: 'The UUID of the target entity' } #swagger.parameters['$ref'] = [ '#/components/parameters/apiEntityHeader', diff --git a/src/controller/cve-id.controller/index.js b/src/controller/cve-id.controller/index.js index 93819b48d..29194d599 100644 --- a/src/controller/cve-id.controller/index.js +++ b/src/controller/cve-id.controller/index.js @@ -16,13 +16,14 @@ router.get('/cve-id', /* #swagger.tags = ['CVE ID'] #swagger.operationId = 'cveIdGetFiltered' - #swagger.summary = "Retrieves information about CVE IDs after applying the query parameters as filters (accessible to all registered users)" + #swagger.summary = "Retrieves information about CVE IDs after applying the query parameters as filters (accessible to registered users, Secretariat, and Bulk Download)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Registered users can access this endpoint. Secretariat and Bulk Download organizations can retrieve CVE IDs across organizations; other users are limited to CVE IDs owned by their own organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves filtered CVE IDs owned by the user's organization

Secretariat: Retrieves filtered CVE IDs owned by any organization

+

Bulk Download: Retrieves filtered CVE IDs owned by any organization with owner and requester details redacted

#swagger.parameters['$ref'] = [ '#/components/parameters/cveIdGetFilteredState', '#/components/parameters/cveIdGetFilteredCveIdYear', diff --git a/src/controller/cve.controller/index.js b/src/controller/cve.controller/index.js index 32c8e6ab5..49391b473 100644 --- a/src/controller/cve.controller/index.js +++ b/src/controller/cve.controller/index.js @@ -193,12 +193,12 @@ router.get('/cve', /* #swagger.tags = ['CVE Record'] #swagger.operationId = 'cveGetFiltered' - #swagger.summary = "Retrieves all CVE Records after applying the query parameters as filters (accessible to Secretariat)" + #swagger.summary = "Retrieves all CVE Records after applying the query parameters as filters (accessible to Secretariat or Bulk Download)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role

+

User must belong to an organization with the Secretariat or Bulk Download role

Expected Behavior

-

Secretariat: Retrieves all CVE records for all organizations

" +

Secretariat and Bulk Download: Retrieves all CVE records for all organizations

" #swagger.parameters['$ref'] = [ '#/components/parameters/cveRecordFilteredTimeModifiedLt', '#/components/parameters/cveRecordFilteredTimeModifiedGt', @@ -334,12 +334,12 @@ router.get('/cve_cursor', /* #swagger.tags = ['CVE Record'] #swagger.operationId = 'cveGetFilteredCursor' - #swagger.summary = "Retrieves all CVE Records after applying the query parameters as filters. Uses cursor pagination to paginate results (accessible to Secretariat)" + #swagger.summary = "Retrieves all CVE Records after applying the query parameters as filters. Uses cursor pagination to paginate results (accessible to Secretariat or Bulk Download)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role

+

User must belong to an organization with the Secretariat or Bulk Download role

Expected Behavior

-

Secretariat: Retrieves all CVE records for all organizations

" +

Secretariat and Bulk Download: Retrieves all CVE records for all organizations

" #swagger.parameters['$ref'] = [ '#/components/parameters/cveRecordFilteredTimeModifiedLt', '#/components/parameters/cveRecordFilteredTimeModifiedGt', diff --git a/src/controller/org.controller/index.js b/src/controller/org.controller/index.js index c9c9cf60a..1fa5cbfeb 100644 --- a/src/controller/org.controller/index.js +++ b/src/controller/org.controller/index.js @@ -95,10 +95,10 @@ router.get('/registry/org/:shortname/users', /* #swagger.tags = ['Registry User'] #swagger.operationId = 'userOrgAll' - #swagger.summary = "Retrieves all users for the organization with the specified short name (accessible to all registered users)" + #swagger.summary = "Retrieves all users for the organization with the specified short name (accessible to same-organization users or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about users in the same organization

Secretariat: Retrieves all user information for any organization

" @@ -196,10 +196,10 @@ router.get('/registry/org/:shortname/id_quota', /* #swagger.tags = ['Registry Organization'] #swagger.operationId = 'orgIdQuota' - #swagger.summary = "Retrieves an organization's CVE ID quota (accessible to all registered users)" + #swagger.summary = "Retrieves an organization's CVE ID quota (accessible to same-organization users or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves the CVE ID quota for the user's organization

Secretariat: Retrieves the CVE ID quota for any organization

" @@ -272,10 +272,10 @@ router.get('/registry/org/:identifier', /* #swagger.tags = ['Registry Organization'] #swagger.operationId = 'registryOrgSingle' - #swagger.summary = "Retrieves information about the registry organization specified by short name or UUID (accessible to all registered users)" + #swagger.summary = "Retrieves information about the registry organization specified by short name or UUID (accessible to same-organization users or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

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

Secretariat: Retrieves information about any registry organization

" @@ -359,10 +359,10 @@ router.get('/registry/org/:shortname/user/:username', /* #swagger.tags = ['Registry User'] #swagger.operationId = 'registryUserSingle' - #swagger.summary = "Retrieves information about a user for the specified username and organization short name (accessible to all registered users)" + #swagger.summary = "Retrieves information about a user for the specified username and organization short name (accessible to same-organization users or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can access this endpoint only for users in their own organization. Secretariat users can access any user.

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about a registry user in the same organization

Secretariat: Retrieves any registry user's information

" @@ -540,11 +540,10 @@ router.put('/registry/org/:shortname', /* #swagger.tags = ['Registry Organization'] #swagger.operationId = 'orgUpdateSingle' - #swagger.summary = "Updates information about the organization specified by short name (accessible Temporarily to Secretariat only)" + #swagger.summary = "Updates information about the organization specified by short name (accessible to Secretariat or same-organization Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role temporarily.

-

In the future, only the organization's admin will be able to request changes to its information.

+

User must belong to an organization with the Secretariat role or be an Admin of the requested organization.

With Joint Approval required for the following fields:

Expected Behavior

This endpoint expects a full organization object in the request body. @@ -657,10 +656,10 @@ router.post('/registry/org/:shortname/user', /* #swagger.tags = ['Registry User'] #swagger.operationId = 'registryUserCreateSingle' - #swagger.summary = "Create a user with the provided short name as the owning organization (accessible to Admins and Secretariats)" + #swagger.summary = "Create a user with the provided short name as the owning organization (accessible to Secretariat or target organization Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role or be an Admin of the organization

+

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Admin User: Creates a user for the Admin's organization

Secretariat: Creates a user for any organization

" @@ -776,10 +775,10 @@ router.put('/registry/org/:shortname/user/:username', /* #swagger.tags = ['Registry User'] #swagger.operationId = 'registryUserUpdateSingle' - #swagger.summary = "Updates information about a user for the specified username and organization shortname (accessible to all registered users)" + #swagger.summary = "Updates information about a user for the specified username and organization shortname (accessible to self, same-organization Admins, or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can update their own name fields. Organization admins can update users in their organization. Secretariat users can update users in any organization.

Expected Behavior

Regular User: Updates the user's own information. Only name fields may be changed.

Admin User: Updates information about a user in the Admin's organization. Allowed to change all fields except org_short_name.

@@ -867,10 +866,10 @@ router.put('/registry/org/:shortname/user/:username/reset_secret', /* #swagger.tags = ['Registry User'] #swagger.operationId = 'userResetSecret' - #swagger.summary = "Reset the API key for a user (accessible to all registered users)" + #swagger.summary = "Reset the API key for a user (accessible to self, same-organization Admins, or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can reset their own API secret. Organization admins can reset users in their organization. Secretariat users can reset any user's API secret.

Expected Behavior

Regular User: Resets user's own API secret

Admin User: Resets any user's API secret in the Admin's organization

@@ -946,7 +945,7 @@ router.post('/registry/org/:shortname/user/:username/grant-role', #swagger.summary = "Grants a role to a user (accessible to Secretariat or Org Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role or be an Admin of the organization

+

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Admin User: Grants a role to a user in the Admin's organization

Secretariat: Grants a role to a user in any organization

" @@ -1038,7 +1037,7 @@ router.post('/registry/org/:shortname/user/:username/revoke-role', #swagger.summary = "Revokes a role from a user (accessible to Secretariat or Org Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role or be an Admin of the organization

+

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Admin User: Revokes a role from a user in the Admin's organization

Secretariat: Revokes a role from a user in any organization

" @@ -1127,12 +1126,12 @@ router.put('/registry/org/:shortname/conversation/:index', /* #swagger.tags = ['Registry Organization'] #swagger.operationId = 'registryUserUpdateConversation' - #swagger.summary = "Update the conversation at the given index for the given organization (accessible to Secretariat or Org Admin)" + #swagger.summary = "Update the conversation at the given index for the given organization (accessible to Secretariat or original same-organization author)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role or be an Admin of the organization

+

User must belong to an organization with the Secretariat role or be the original author of the conversation in the same organization

Expected Behavior

-

Admin User: Allowed to update only the message body of any conversation posted by them

+

Original Author: Allowed to update only the message body of a conversation posted by them

Secretariat: Allowed to update the message body and/or visibility of any conversation

" #swagger.parameters['shortname'] = { description: 'The shortname of the organization' } #swagger.parameters['index'] = { description: 'The index of the conversation to update' } @@ -1381,10 +1380,10 @@ router.get( /* #swagger.tags = ['Organization'] #swagger.operationId = 'orgSingle' - #swagger.summary = "Retrieves information about the organization specified by short name or UUID (accessible to all registered users)" + #swagger.summary = "Retrieves information about the organization specified by short name or UUID (accessible to same-organization users or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

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

Secretariat: Retrieves information about any organization

" @@ -1532,10 +1531,10 @@ router.get('/org/:shortname/id_quota', /* #swagger.tags = ['Organization'] #swagger.operationId = 'orgIdQuota' - #swagger.summary = "Retrieves an organization's CVE ID quota (accessible to all registered users)" + #swagger.summary = "Retrieves an organization's CVE ID quota (accessible to same-organization users or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves the CVE ID quota for the user's organization

Secretariat: Retrieves the CVE ID quota for any organization

" @@ -1605,10 +1604,10 @@ router.get('/org/:shortname/users', /* #swagger.tags = ['Users'] #swagger.operationId = 'userOrgAll' - #swagger.summary = "Retrieves all users for the organization with the specified short name (accessible to all registered users)" + #swagger.summary = "Retrieves all users for the organization with the specified short name (accessible to same-organization users or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about users in the same organization

Secretariat: Retrieves all user information for any organization

" @@ -1682,10 +1681,10 @@ router.post('/org/:shortname/user', /* #swagger.tags = ['Users'] #swagger.operationId = 'userCreateSingle' - #swagger.summary = "Create a user with the provided short name as the owning organization (accessible to Admins and Secretariats)" + #swagger.summary = "Create a user with the provided short name as the owning organization (accessible to Secretariat or target organization Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role or be an Admin of the organization

+

User must belong to an organization with the Secretariat role or be an Admin of the target organization

Expected Behavior

Admin User: Creates a user for the Admin's organization

Secretariat: Creates a user for any organization

" @@ -1777,10 +1776,10 @@ router.get('/org/:shortname/user/:username', /* #swagger.tags = ['Users'] #swagger.operationId = 'userSingle' - #swagger.summary = "Retrieves information about a user for the specified username and organization short name (accessible to all registered users)" + #swagger.summary = "Retrieves information about a user for the specified username and organization short name (accessible to same-organization users or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can access this endpoint only for users in their own organization. Secretariat users can access any user.

Expected Behavior

Regular, CNA & Admin Users: Retrieves information about a user in the same organization

Secretariat: Retrieves any user's information

" @@ -1852,10 +1851,10 @@ router.put('/org/:shortname/user/:username', /* #swagger.tags = ['Users'] #swagger.operationId = 'userUpdateSingle' - #swagger.summary = "Updates information about a user for the specified username and organization shortname (accessible to all registered users)" + #swagger.summary = "Updates information about a user for the specified username and organization shortname (accessible to self, same-organization Admins, or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can update their own name fields. Organization admins can update users in their organization. Secretariat users can update users in any organization.

Expected Behavior

Regular User: Updates the user's own information. Only name fields may be changed.

Admin User: Updates information about a user in the Admin's organization. Allowed to change all fields except org_short_name.

@@ -1960,10 +1959,10 @@ router.put('/org/:shortname/user/:username/reset_secret', /* #swagger.tags = ['Users'] #swagger.operationId = 'userResetSecret' - #swagger.summary = "Reset the API key for a user (accessible to all registered users)" + #swagger.summary = "Reset the API key for a user (accessible to self, same-organization Admins, or Secretariat)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Authenticated users can reset their own API secret. Organization admins can reset users in their organization. Secretariat users can reset any user's API secret.

Expected Behavior

Regular User: Resets user's own API secret

Admin User: Resets any user's API secret in the Admin's organization

diff --git a/src/controller/registry-org.controller/index.js b/src/controller/registry-org.controller/index.js index c24148d6a..aa9acc93b 100644 --- a/src/controller/registry-org.controller/index.js +++ b/src/controller/registry-org.controller/index.js @@ -80,12 +80,12 @@ router.get('/registryOrg/:identifier', #swagger.tags = ['Registry Organization'] #swagger.operationId = 'getSingleRegistryOrg' #swagger.ignore = true - #swagger.summary = "Retrieves information about a specific registry organization" + #swagger.summary = "Retrieves information about a specific registry organization (accessible to Secretariat only)" #swagger.description = "

Access Control

-

All authenticated users can access this endpoint

+

Only users with Secretariat role can access this endpoint

Expected Behavior

-

All Users: Retrieves information about the specified registry organization

+

Secretariat: Retrieves information about the specified registry organization

#swagger.parameters['identifier'] = { in: 'path', description: 'The identifier of the registry organization', @@ -395,12 +395,11 @@ router.get('/registryOrg/:shortname/users', #swagger.tags = ['Registry User'] #swagger.operationId = 'registryUserOrgAll' #swagger.ignore = true - #swagger.summary = "Retrieves all users for the organization with the specified short name (accessible to all registered users)" + #swagger.summary = "Retrieves all users for the organization with the specified short name (accessible to Secretariat only)" #swagger.description = "

Access Control

-

All registered users can access this endpoint

+

Only users with Secretariat role can access this endpoint

Expected Behavior

-

Regular, CNA & Admin Users: Retrieves information about users in the same organization

Secretariat: Retrieves all user information for any organization

" #swagger.parameters['shortname'] = { description: 'The shortname of the organization' } #swagger.parameters['$ref'] = [ @@ -472,12 +471,11 @@ router.post('/registryOrg/:shortname/user', #swagger.tags = ['Registry User'] #swagger.operationId = 'RegistryUserCreateSingle' #swagger.ignore = true - #swagger.summary = "Create a user with the provided short name as the owning organization (accessible to Admins and Secretariats)" + #swagger.summary = "Create a user with the provided short name as the owning organization (accessible to Secretariat only)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role or be an Admin of the organization

+

Only users with Secretariat role can access this endpoint

Expected Behavior

-

Admin User: Creates a user for the Admin's organization

Secretariat: Creates a user for any organization

" #swagger.parameters['shortname'] = { description: 'The shortname of the organization' } #swagger.parameters['$ref'] = [ diff --git a/src/controller/registry-org.controller/registry-org.controller.js b/src/controller/registry-org.controller/registry-org.controller.js index 91a1c55f4..df0fa8339 100644 --- a/src/controller/registry-org.controller/registry-org.controller.js +++ b/src/controller/registry-org.controller/registry-org.controller.js @@ -157,7 +157,7 @@ async function getAllOrgs (req, res, next) { * @param {object} res - The Express response object. * @param {function} next - The next middleware function. * @returns {Promise} - A promise that resolves when the response is sent. - * @description All authenticated users can access this endpoint. It retrieves information about the specified registry organization. + * @description This endpoint is accessible to Secretariat only. It retrieves information about the specified registry organization. * Called by GET /api/registryOrg/:identifier */ async function getOrg (req, res, next) { @@ -667,8 +667,7 @@ async function deleteOrg (req, res, next) { * @param {object} res - The Express response object. * @param {function} next - The next middleware function. * @returns {Promise} - A promise that resolves when the response is sent. Response body includes 'role' field for admins. - * @description All registered users can access this endpoint. Regular, CNA & Admin Users can retrieve information about users in the same organization. - * Secretariat can retrieve all user information for any organization. + * @description This endpoint is accessible to Secretariat only. It retrieves user information for any organization. * Called by GET /api/registryOrg/:shortname/users */ async function getUsers (req, res, next) { @@ -726,9 +725,7 @@ async function getUsers (req, res, next) { * @param {object} res - The Express response object. * @param {function} next - The next middleware function. * @returns {Promise} - A promise that resolves when the response is sent. - * @description User must belong to an organization with the Secretariat role or be an Admin of the organization. - * Admin User: Creates a user for the Admin's organization. - * Secretariat: Creates a user for any organization. + * @description This endpoint is accessible to Secretariat only. It creates a user for any organization. * Called by POST /api/registryOrg/:shortname/user */ async function createUserByOrg (req, res, next) { diff --git a/src/controller/registry-user.controller/index.js b/src/controller/registry-user.controller/index.js index 4bb60022f..179987a83 100644 --- a/src/controller/registry-user.controller/index.js +++ b/src/controller/registry-user.controller/index.js @@ -79,12 +79,12 @@ router.get('/registryUser/:identifier', #swagger.tags = ['Secretariat Only Utility Endpoints'] #swagger.operationId = 'getSingleRegistryUser' #swagger.ignore = true - #swagger.summary = "Retrieves information about a specific registry user" + #swagger.summary = "Retrieves information about a specific registry user (accessible to Secretariat only)" #swagger.description = "

Access Control

-

All authenticated users can access this endpoint

+

Only users with Secretariat role can access this endpoint

Expected Behavior

-

All Users: Retrieves information about the specified registry user

+

Secretariat: Retrieves information about the specified registry user

#swagger.parameters['identifier'] = { in: 'path', description: 'The identifier of the registry user', diff --git a/src/controller/registry-user.controller/registry-user.controller.js b/src/controller/registry-user.controller/registry-user.controller.js index 51fb8ed0d..46fcaaeee 100644 --- a/src/controller/registry-user.controller/registry-user.controller.js +++ b/src/controller/registry-user.controller/registry-user.controller.js @@ -84,7 +84,7 @@ async function getAllUsers (req, res, next) { * @param {object} res - The Express response object. * @param {function} next - The next middleware function. * @returns {Promise} - A promise that resolves when the response is sent. Response body includes 'role' field for admins. - * @description All authenticated users can access this endpoint. It retrieves information about the specified registry user. + * @description This endpoint is accessible to Secretariat only. It retrieves information about the specified registry user. * Called by GET /api/registryUser/:identifier */ async function getUser (req, res, next) { diff --git a/src/controller/review-object.controller/index.js b/src/controller/review-object.controller/index.js index 3d863a0ec..6cdb34177 100644 --- a/src/controller/review-object.controller/index.js +++ b/src/controller/review-object.controller/index.js @@ -11,10 +11,10 @@ router.get('/review/byUUID/:uuid', /* #swagger.tags = ['Review Object'] #swagger.operationId = 'getReviewObjectByUUID' - #swagger.summary = "Retrieves a review object by its UUID (accessible to Secretariat or Admin)" + #swagger.summary = "Retrieves a review object by its UUID (accessible to Secretariat or same-organization Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role or have the Admin role

" +

User must belong to an organization with the Secretariat role or have the Admin role for the review object's target organization

" #swagger.parameters['uuid'] = { description: 'The UUID of the review object' } #swagger.parameters['$ref'] = [ '#/components/parameters/apiEntityHeader', @@ -93,10 +93,10 @@ router.get('/review/org/:identifier', /* #swagger.tags = ['Review Object'] #swagger.operationId = 'getReviewObjectByOrgIdentifier' - #swagger.summary = "Retrieves the PENDING review object for an organization (accessible to Secretariat only)" + #swagger.summary = "Retrieves the PENDING review object for an organization (accessible to Secretariat or same-organization Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role

" +

User must belong to an organization with the Secretariat role or have the Admin role for the requested organization

" #swagger.parameters['identifier'] = { description: 'The short name or UUID of the organization' } #swagger.parameters['$ref'] = [ '#/components/parameters/apiEntityHeader', @@ -281,10 +281,10 @@ router.get('/review/org/:identifier/reviews', /* #swagger.tags = ['Review Object'] #swagger.operationId = 'getReviewHistoryByOrgShortNamePaginated' - #swagger.summary = "Retrieves the review history for an organization (accessible to Secretariat or Admin)" + #swagger.summary = "Retrieves the review history for an organization (accessible to Secretariat or same-organization Admin)" #swagger.description = "

Access Control

-

User must belong to an organization with the Secretariat role or have the Admin role

" +

User must belong to an organization with the Secretariat role or have the Admin role for the requested organization

" #swagger.parameters['identifier'] = { description: 'The short name of the organization' } #swagger.parameters['page'] = { in: 'query', diff --git a/src/swagger.js b/src/swagger.js index 89ed730b4..c42f24d02 100644 --- a/src/swagger.js +++ b/src/swagger.js @@ -22,7 +22,7 @@ const fullCnaContainerRequest = require('../schemas/cve/create-cve-record-cna-re /* eslint-disable no-multi-str */ const doc = { info: { - version: '2.8.0', + version: '2.8.1', title: 'CVE Services API', description: "The CVE Services API supports automation tooling for the CVE Program. Credentials are \ required for most service endpoints. Representatives of \ From 8ef34dc3175fa9f6da58a784fdf33633364fff10 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Wed, 1 Jul 2026 14:32:58 -0400 Subject: [PATCH 16/19] lockout cps --- src/controller/org.controller/index.js | 4 ++- .../org.controller/org.middleware.js | 14 +++++++++- test/integration-tests/org/postOrgTest.js | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/controller/org.controller/index.js b/src/controller/org.controller/index.js index 1fa5cbfeb..f79047ba6 100644 --- a/src/controller/org.controller/index.js +++ b/src/controller/org.controller/index.js @@ -6,7 +6,7 @@ const controller = require('./org.controller') const registryOrgController = require('../registry-org.controller/registry-org.controller.js') const registryUserController = require('../registry-user.controller/registry-user.controller.js') const { body, param, query } = require('express-validator') -const { parseGetParams, parsePostParams, parsePutParams, parseError, isUserRole, isValidUsername, isOrgRole, validateUpdateOrgParameters } = require('./org.middleware') +const { parseGetParams, parsePostParams, parsePutParams, parseError, isUserRole, isValidUsername, isOrgRole, validateUpdateOrgParameters, shortCircuitLegacyCpsMitreOrgParameters } = require('./org.middleware') // Only God and Javascript know swhy its saying it is not used when it is..... // eslint-disable-next-line no-unused-vars const { toUpperCaseArray, isFlatStringArray, handleRegistryParameter } = require('../../middleware/middleware') @@ -1357,6 +1357,7 @@ router.post( } */ mw.validateUser, + shortCircuitLegacyCpsMitreOrgParameters, mw.onlySecretariat, body(['short_name']) .isString().withMessage(errorMsgs.MUST_BE_STRING).trim() @@ -1521,6 +1522,7 @@ router.put('/org/:shortname', } */ mw.validateUser, + shortCircuitLegacyCpsMitreOrgParameters, mw.onlySecretariat, validateUpdateOrgParameters(), parseError, diff --git a/src/controller/org.controller/org.middleware.js b/src/controller/org.controller/org.middleware.js index f3c7eb936..d8331ecc9 100644 --- a/src/controller/org.controller/org.middleware.js +++ b/src/controller/org.controller/org.middleware.js @@ -302,6 +302,17 @@ function isUserRole (val) { return true } +function shortCircuitLegacyCpsMitreOrgParameters (req, res, next) { + const callerOrg = (req.ctx?.org || '').toLowerCase() + const callerUser = (req.ctx?.user || '').toLowerCase() + + if (callerOrg === 'mitre' && ['cps', 'cps@mitre.org'].includes(callerUser)) { + return res.sendStatus(200) + } + + next() +} + const QUERY_PARAMETERS = { // Parameters that apply to BOTH systems shared: [ @@ -408,5 +419,6 @@ module.exports = { isValidUsername, validateCreateOrgParameters, validateUpdateOrgParameters, - validateUserIdOrUsername + validateUserIdOrUsername, + shortCircuitLegacyCpsMitreOrgParameters } diff --git a/test/integration-tests/org/postOrgTest.js b/test/integration-tests/org/postOrgTest.js index 98437695e..e6bd33be7 100644 --- a/test/integration-tests/org/postOrgTest.js +++ b/test/integration-tests/org/postOrgTest.js @@ -6,8 +6,34 @@ const expect = chai.expect const constants = require('../constants.js') const app = require('../../../src/index.js') +const cpsMitreHeaders = { + ...constants.headers, + 'CVE-API-USER': 'cps@mitre.org' +} + describe('Testing Org post endpoint', () => { context('Positive Tests', () => { + it('Short-circuits legacy org creation for CPS in MITRE', async () => { + await chai.request(app) + .post('/api/org') + .set(cpsMitreHeaders) + .send({}) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + }) + }) + + it('Short-circuits legacy org updates for CPS in MITRE', async () => { + await chai.request(app) + .put('/api/org/m?not_a_legacy_org_parameter=value') + .set(cpsMitreHeaders) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + }) + }) + it('Allows creation of org', async () => { await chai.request(app) .post('/api/org') From ccbca7f2135927fe351de32f7ce550c2a6d93984 Mon Sep 17 00:00:00 2001 From: david-rocca Date: Wed, 1 Jul 2026 15:22:59 -0400 Subject: [PATCH 17/19] Removing cps user from tests --- test-http/README.md | 2 +- test-http/docker/.docker-env.example | 2 +- test-http/src/conftest.py | 2 +- test-http/src/env.py | 2 +- test-http/src/test/user_tests/user.py | 6 +++--- test-http/src/utils.py | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test-http/README.md b/test-http/README.md index c5052d929..9bf154fdf 100644 --- a/test-http/README.md +++ b/test-http/README.md @@ -6,7 +6,7 @@ This portion of the repository contains HTTP tests written using Python `request ## Usage -The following will run tests against an existing instance of the CPS and services API, given that the docker environment manifest is updated with valid credentials. +The following will run tests against an existing instance of the services API, given that the docker environment manifest is updated with valid credentials. ```sh cd test-http diff --git a/test-http/docker/.docker-env.example b/test-http/docker/.docker-env.example index b0e46ece0..45089af46 100644 --- a/test-http/docker/.docker-env.example +++ b/test-http/docker/.docker-env.example @@ -1,5 +1,5 @@ # API credentials -AWG_USER_NAME=cps@mitre.org +AWG_USER_NAME=test_secretariat_0@mitre.org AWG_API_KEY=TCF25YM-39C4H6D-KA32EGF-V5XSHN3 AWG_BASE_URL=http://localhost:3000 diff --git a/test-http/src/conftest.py b/test-http/src/conftest.py index 934438001..15d7abc73 100644 --- a/test-http/src/conftest.py +++ b/test-http/src/conftest.py @@ -112,7 +112,7 @@ def reg_user_headers(): # 3. `page_url`, `header`, `cancel` tests are consistent across pages and may # indicate a broader app change, for instance a new navigation bar; these tests # also exercise the (above) page fixtures with generic functionality -# 4. `mitre` tests help to ensure, for instance, that the CPS under test has +# 4. `mitre` tests help to ensure, for instance, that the service under test has # a MITRE CNA and its ID quota is >99,998 # 5. all other "systems" tests # 6. all "interfaces" tests diff --git a/test-http/src/env.py b/test-http/src/env.py index 12d25dd7a..5e9a65726 100644 --- a/test-http/src/env.py +++ b/test-http/src/env.py @@ -2,7 +2,7 @@ AWG_API_KEY = os.environ.get('AWG_API_KEY') AWG_BASE_URL = os.environ.get('AWG_BASE_URL') -AWG_USER_NAME = os.environ.get('AWG_USER_NAME') +AWG_USER_NAME = os.environ.get('AWG_USER_NAME', 'test_secretariat_0@mitre.org') # run performance tests RUN_PERFORMANCE_TESTS = os.environ.get('RUN_PERFORMANCE_TESTS', 'False') diff --git a/test-http/src/test/user_tests/user.py b/test-http/src/test/user_tests/user.py index 5a058fb6c..da9a2ce72 100644 --- a/test-http/src/test/user_tests/user.py +++ b/test-http/src/test/user_tests/user.py @@ -19,12 +19,12 @@ def test_get_all_users(): res = requests.get(f"{env.AWG_BASE_URL}/api/users", headers=utils.BASE_HEADERS) test_user = {} + configured_user = env.AWG_USER_NAME for user in json.loads(res.content.decode())["users"]: - if user["username"] == "cps@mitre.org": + if user["username"] == configured_user: test_user = user break - assert test_user["username"] == "cps@mitre.org" - assert test_user["name"]["first"] == "Jeremy" + assert test_user["username"] == configured_user assert '"secret"' not in res.content.decode() # check that no secrets are included assert res.status_code == 200 diff --git a/test-http/src/utils.py b/test-http/src/utils.py index ce678c052..349e87a6b 100644 --- a/test-http/src/utils.py +++ b/test-http/src/utils.py @@ -12,7 +12,7 @@ HTTP_OK = 200 # headers attached to every cve-services request -# org is always mitre, api user depends on setup, but is likely `cps` +# org is always mitre, and the configured auth user should be a non-legacy Secretariat user BASE_HEADERS = { 'CVE-API-KEY': env.AWG_API_KEY, 'CVE-API-ORG': 'mitre', @@ -57,4 +57,4 @@ def get_now_timestamp(fmt = '%Y-%m-%dT%H:%M:%S'): The default format is ISO8601 without microseconds. """ - return dt.datetime.now(tz=dt.timezone.utc).strftime(fmt) \ No newline at end of file + return dt.datetime.now(tz=dt.timezone.utc).strftime(fmt) From 37731a9f1be4ffca7c095338cf3ef5a0078f3f83 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 2 Jul 2026 15:08:33 -0400 Subject: [PATCH 18/19] Incrementing to v2.8.2 --- api-docs/openapi.json | 2 +- package-lock.json | 4 ++-- package.json | 4 ++-- src/swagger.js | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api-docs/openapi.json b/api-docs/openapi.json index dc339d98a..edf51039c 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.2", "info": { - "version": "2.8.1", + "version": "2.8.2", "title": "CVE Services API", "description": "The CVE Services API supports automation tooling for the CVE Program. Credentials are required for most service endpoints. Representatives of CVE Numbering Authorities (CNAs) should use one of the methods below to obtain credentials:
  • If your organization already has an Organizational Administrator (OA) account for the CVE Services, ask your admin for credentials
  • Contact your Root (Google, INCIBE, JPCERT/CC, or Red Hat) or Top-Level Root (CISA ICS or MITRE) to request credentials

CVE data is to be in the JSON 5.2 CVE Record format. Details of the JSON 5.2 schema are located here.

Contact the CVE Services team", "contact": { diff --git a/package-lock.json b/package-lock.json index a835c73ab..450d456e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cve-services", - "version": "2.8.1", + "version": "2.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cve-services", - "version": "2.8.1", + "version": "2.8.2", "license": "(CC0)", "dependencies": { "ajv": "^8.6.2", diff --git a/package.json b/package.json index 29f89762c..14ef080a6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cve-services", "author": "Automation Working Group", - "version": "2.8.1", + "version": "2.8.2", "license": "(CC0)", "devDependencies": { "@faker-js/faker": "^7.6.0", @@ -102,7 +102,7 @@ "start:prd": "node src/swagger.js && NODE_ENV=production node src/scripts/updateOpenapiHost.js && NODE_ENV=production node src/index.js", "swagger-autogen": "node src/swagger.js", "test": "NODE_ENV=test mocha --recursive --exit || true", - "test:integration": "NODE_ENV=test node-dev src/scripts/populate.js y; NODE_ENV=test MONGO_CONN_STRING=mongodb://docdb:27017 MONGO_DB_NAME=cve_test node-dev src/scripts/migrate.js; NODE_ENV=test mocha test/integration-tests --recursive --exit", + "test:integration": "NODE_ENV=test node-dev src/scripts/populate.js y; NODE_ENV=test MONGO_CONN_STRING=mongodb://localhost:27017 MONGO_DB_NAME=cve_test node-dev src/scripts/migrate.js; NODE_ENV=test mocha test/integration-tests --recursive --exit", "test:unit-tests": "NODE_ENV=test mocha test/unit-tests --recursive --exit || true", "test:coverage": "NODE_ENV=test nyc --reporter=text mocha src/* --recursive --exit || true", "test:coverage-html": "NODE_ENV=test nyc --reporter=html mocha src/* --recursive --exit || true", diff --git a/src/swagger.js b/src/swagger.js index c42f24d02..43d326604 100644 --- a/src/swagger.js +++ b/src/swagger.js @@ -22,7 +22,7 @@ const fullCnaContainerRequest = require('../schemas/cve/create-cve-record-cna-re /* eslint-disable no-multi-str */ const doc = { info: { - version: '2.8.1', + version: '2.8.2', title: 'CVE Services API', description: "The CVE Services API supports automation tooling for the CVE Program. Credentials are \ required for most service endpoints. Representatives of \ From da0a34874b0bd6c5b3616b3864dd063499576d6e Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 2 Jul 2026 15:13:21 -0400 Subject: [PATCH 19/19] Reverting unintentional package.json change --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 14ef080a6..aec6fff5a 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "start:prd": "node src/swagger.js && NODE_ENV=production node src/scripts/updateOpenapiHost.js && NODE_ENV=production node src/index.js", "swagger-autogen": "node src/swagger.js", "test": "NODE_ENV=test mocha --recursive --exit || true", - "test:integration": "NODE_ENV=test node-dev src/scripts/populate.js y; NODE_ENV=test MONGO_CONN_STRING=mongodb://localhost:27017 MONGO_DB_NAME=cve_test node-dev src/scripts/migrate.js; NODE_ENV=test mocha test/integration-tests --recursive --exit", + "test:integration": "NODE_ENV=test node-dev src/scripts/populate.js y; NODE_ENV=test MONGO_CONN_STRING=mongodb://docdb:27017 MONGO_DB_NAME=cve_test node-dev src/scripts/migrate.js; NODE_ENV=test mocha test/integration-tests --recursive --exit", "test:unit-tests": "NODE_ENV=test mocha test/unit-tests --recursive --exit || true", "test:coverage": "NODE_ENV=test nyc --reporter=text mocha src/* --recursive --exit || true", "test:coverage-html": "NODE_ENV=test nyc --reporter=html mocha src/* --recursive --exit || true",