diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 2eb805748..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:
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": { @@ -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": "All registered users can access this endpoint
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": "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.
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": "User must belong to an organization with the Secretariat role
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": "User must belong to an organization with the Secretariat or Bulk Download role
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": "User must belong to an organization with the Secretariat role
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": "User must belong to an organization with the Secretariat or Bulk Download role
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": "All registered users can access this endpoint
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": "Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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": "All registered users can access this endpoint
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": "Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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": "All registered users can access this endpoint
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": "Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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": "All registered users can access this endpoint
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": "Authenticated users can access this endpoint only for users in their own organization. Secretariat users can access any user.
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": "All registered users can access this endpoint
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": "Authenticated users can update their own name fields. Organization admins can update users in their organization. Secretariat users can update users in any organization.
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": "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:
Secretariat: Updates any organization's information
Organization Admin: Requests changes to its organization's 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:
Secretariat: Updates any organization's information
Organization Admin: Requests changes to its organization's information
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
", + "summary": "Create a user with the provided short name as the owning organization (accessible to Secretariat or target organization Admin)", + "description": "User must belong to an organization with the Secretariat role or be an Admin of the target organization
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": "All registered users can access this endpoint
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": "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.
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": "User must belong to an organization with the Secretariat role or be an Admin of the organization
Admin User: Grants a role to a user in the Admin's organization
Secretariat: Grants a role to a user in any organization
", + "description": "User must belong to an organization with the Secretariat role or be an Admin of the target organization
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": "User must belong to an organization with the Secretariat role or be an Admin of the organization
Admin User: Revokes a role from a user in the Admin's organization
Secretariat: Revokes a role from a user in any organization
", + "description": "User must belong to an organization with the Secretariat role or be an Admin of the target organization
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": "User must belong to an organization with the Secretariat role or be an Admin of the organization
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": "User must belong to an organization with the Secretariat role or be the original author of the conversation in the same organization
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": "All registered users can access this endpoint
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": "Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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": "All registered users can access this endpoint
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": "Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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": "All registered users can access this endpoint
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": "Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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": "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
", + "summary": "Create a user with the provided short name as the owning organization (accessible to Secretariat or target organization Admin)", + "description": "User must belong to an organization with the Secretariat role or be an Admin of the target organization
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": "All registered users can access this endpoint
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": "Authenticated users can access this endpoint only for users in their own organization. Secretariat users can access any user.
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": "All registered users can access this endpoint
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": "Authenticated users can update their own name fields. Organization admins can update users in their organization. Secretariat users can update users in any organization.
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": "All registered users can access this endpoint
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": "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.
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": "User must belong to an organization with the Secretariat role
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": "User must belong to an organization with the Secretariat role or be an Admin of the target organization
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": "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": "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": "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": "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": "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": "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/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..aec6fff5a 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", 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/controller/audit.controller/audit.controller.js b/src/controller/audit.controller/audit.controller.js index 467b698f1..23b3573b3 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,15 @@ 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() + try { session.startTransaction() @@ -207,8 +214,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 +237,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 +250,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 +277,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 +344,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 +357,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/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/conversation.controller.js b/src/controller/conversation.controller/conversation.controller.js index e79d60227..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) @@ -45,6 +46,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 +55,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 +101,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/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..25674279f 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,20 +202,23 @@ 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 ) -// 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 = "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
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', @@ -301,6 +307,8 @@ router.post('/conversation/target/:uuid', mw.validateUser, mw.onlySecretariatOrAdmin, param(['uuid']).isUUID(4), + parseError, + parseUuidParams, controller.createConversationForTargetUUID ) @@ -410,6 +418,8 @@ router.put('/conversation/:uuid', mw.validateUser, mw.onlySecretariat, param(['uuid']).isUUID(4), + parseError, + parseUuidParams, controller.updateConversationByUUID ) 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/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 = "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.
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/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/src/controller/cve.controller/index.js b/src/controller/cve.controller/index.js index c5a78503e..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 = "User must belong to an organization with the Secretariat role
+User must belong to an organization with the Secretariat or Bulk Download role
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 = "User must belong to an organization with the Secretariat role
+User must belong to an organization with the Secretariat or Bulk Download role
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', @@ -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/src/controller/org.controller/index.js b/src/controller/org.controller/index.js index c9c9cf60a..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') @@ -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 = "All registered users can access this endpoint
+Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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 = "All registered users can access this endpoint
+Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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 = "All registered users can access this endpoint
+Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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 = "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.
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 = "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:
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
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 = "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.
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 = "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.
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 = "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
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 = "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
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 = "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
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' } @@ -1358,6 +1357,7 @@ router.post( } */ mw.validateUser, + shortCircuitLegacyCpsMitreOrgParameters, mw.onlySecretariat, body(['short_name']) .isString().withMessage(errorMsgs.MUST_BE_STRING).trim() @@ -1381,10 +1381,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 = "All registered users can access this endpoint
+Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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
" @@ -1522,6 +1522,7 @@ router.put('/org/:shortname', } */ mw.validateUser, + shortCircuitLegacyCpsMitreOrgParameters, mw.onlySecretariat, validateUpdateOrgParameters(), parseError, @@ -1532,10 +1533,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 = "All registered users can access this endpoint
+Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
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 +1606,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 = "All registered users can access this endpoint
+Authenticated users can access this endpoint only for their own organization. Secretariat users can access any organization.
Regular, CNA & Admin Users: Retrieves information about users in the same organization
Secretariat: Retrieves all user information for any organization
" @@ -1682,10 +1683,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 = "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
Admin User: Creates a user for the Admin's organization
Secretariat: Creates a user for any organization
" @@ -1777,10 +1778,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 = "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.
Regular, CNA & Admin Users: Retrieves information about a user in the same organization
Secretariat: Retrieves any user's information
" @@ -1852,10 +1853,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 = "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.
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 +1961,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 = "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.
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/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 {PromiseAll authenticated users can access this endpoint
+Only users with Secretariat role can access this endpoint
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 = "All registered users can access this endpoint
+Only users with Secretariat role can access this endpoint
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 = "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
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 ad66e2c25..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 {PromiseAll authenticated users can access this endpoint
+Only users with Secretariat role can access this endpoint
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 c1d01a1ca..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 {PromiseUser 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 = "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 = "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/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/src/middleware/middleware.js b/src/middleware/middleware.js index 6a9206b6b..aa32a66b6 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -24,12 +24,18 @@ 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, + authenticationChecked: 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() @@ -79,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 }) } @@ -153,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) { @@ -392,7 +400,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))) @@ -424,13 +432,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/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/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/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() } 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/src/swagger.js b/src/swagger.js index 89ed730b4..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.0', + 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 \ 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/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-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) 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(), 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 () => { 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/integration-tests/cve-id/getCveIdTest.js b/test/integration-tests/cve-id/getCveIdTest.js index 81ff1cc6d..053b9b84f 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) @@ -257,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']) @@ -284,5 +313,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/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/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/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/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/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 = { 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') + }) +}) 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/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') 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') 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) + }) +}) 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) diff --git a/test/unit-tests/cve/cveGetAllTest.js b/test/unit-tests/cve/cveGetAllTest.js index fea354ce6..c4b0021d5 100644 --- a/test/unit-tests/cve/cveGetAllTest.js +++ b/test/unit-tests/cve/cveGetAllTest.js @@ -19,6 +19,155 @@ 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('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 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 + }) + }) +}) 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]) }) }) 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) + }) +}) 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', () => {