diff --git a/schemas/registry-org/BaseOrg.json b/schemas/registry-org/BaseOrg.json index 5ece322e3..718898f27 100644 --- a/schemas/registry-org/BaseOrg.json +++ b/schemas/registry-org/BaseOrg.json @@ -167,7 +167,7 @@ "properties": { "cve_website_update_date": { "type": "string", - "format": "date-time" + "format": "date" }, "cve_website_update_needed": { "type": "boolean" diff --git a/schemas/registry-org/create-registry-org-request.json b/schemas/registry-org/create-registry-org-request.json index de38a4d67..999a7d036 100644 --- a/schemas/registry-org/create-registry-org-request.json +++ b/schemas/registry-org/create-registry-org-request.json @@ -166,7 +166,7 @@ "properties": { "cve_website_update_date": { "type": "string", - "format": "date-time" + "format": "date" }, "cve_website_update_needed": { "type": "boolean" diff --git a/schemas/registry-org/get-registry-org-response.json b/schemas/registry-org/get-registry-org-response.json index 1d29a56bc..c8e967e1d 100644 --- a/schemas/registry-org/get-registry-org-response.json +++ b/schemas/registry-org/get-registry-org-response.json @@ -129,7 +129,7 @@ "properties": { "cve_website_update_date": { "type": "string", - "format": "date-time" + "format": "date" }, "cve_website_update_needed": { "type": "boolean" diff --git a/schemas/registry-org/list-registry-orgs-response.json b/schemas/registry-org/list-registry-orgs-response.json index 4bd62f90d..da403895a 100644 --- a/schemas/registry-org/list-registry-orgs-response.json +++ b/schemas/registry-org/list-registry-orgs-response.json @@ -158,7 +158,7 @@ "properties": { "cve_website_update_date": { "type": "string", - "format": "date-time" + "format": "date" }, "cve_website_update_needed": { "type": "boolean" diff --git a/schemas/registry-org/update-registry-org-request.json b/schemas/registry-org/update-registry-org-request.json index 5a4aa25a1..8690311f6 100644 --- a/schemas/registry-org/update-registry-org-request.json +++ b/schemas/registry-org/update-registry-org-request.json @@ -182,7 +182,7 @@ "properties": { "cve_website_update_date": { "type": "string", - "format": "date-time" + "format": "date" }, "cve_website_update_needed": { "type": "boolean" diff --git a/src/model/baseorg.js b/src/model/baseorg.js index e881bd0c3..e7e89e82f 100644 --- a/src/model/baseorg.js +++ b/src/model/baseorg.js @@ -1,6 +1,7 @@ const mongoose = require('mongoose') const aggregatePaginate = require('mongoose-aggregate-paginate-v2') const MongoPaging = require('mongo-cursor-pagination') +const { isValidDateOnlyString, normalizeDateOnlyInput } = require('../utils/dateOnly') const toUndefined = value => (value === '' ? undefined : value) @@ -29,7 +30,14 @@ const schema = { partner_number: String, partner_country: String, program_data: { - cve_website_update_date: Date, + cve_website_update_date: { + type: String, + set: normalizeDateOnlyInput, + validate: { + validator: value => value == null || isValidDateOnlyString(value), + message: 'cve_website_update_date must be a date in YYYY-MM-DD format.' + } + }, cve_website_update_needed: Boolean, partner_active_date: String, partner_inactive_date: String, diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 40e235568..db131de10 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -16,6 +16,7 @@ const { createAuditLogEntry, handleAuthorityModelChange } = require('./baseOrgRepositoryHelpers') +const { normalizeOrgCveWebsiteUpdateDate } = require('../utils/dateOnly') /** * @function setAggregateOrgObj @@ -111,6 +112,7 @@ function getOrgProjection (isSecretariat = false) { function filterOrg (orgObj, isSecretariat = false) { const CONSTANTS = getConstants() const _ = require('lodash') + normalizeOrgCveWebsiteUpdateDate(orgObj, { output: true }) let fieldsToOmit = [...CONSTANTS.ORG_EXCLUDED_FIELDS] if (!isSecretariat) { fieldsToOmit = [...fieldsToOmit, ...CONSTANTS.ORG_RESTRICTED_FIELDS] @@ -399,6 +401,7 @@ class BaseOrgRepository extends BaseRepository { if (org.reports_to === null) { delete org.reports_to } + normalizeOrgCveWebsiteUpdateDate(org, { output: true }) }) } @@ -475,6 +478,7 @@ class BaseOrgRepository extends BaseRepository { } } + normalizeOrgCveWebsiteUpdateDate(result, { output: true }) return deepRemoveEmpty(result) } @@ -913,6 +917,8 @@ class BaseOrgRepository extends BaseRepository { const originalPlain = orgObjectOriginal.toObject ? orgObjectOriginal.toObject() : orgObjectOriginal const originalSerialized = JSON.parse(JSON.stringify(originalPlain)) const updatedSerialized = JSON.parse(JSON.stringify(orgObjectUpdated)) + normalizeOrgCveWebsiteUpdateDate(originalSerialized, { output: true }) + normalizeOrgCveWebsiteUpdateDate(updatedSerialized) // Filter the list to find only fields that have changed const changedFields = _.filter(jointApprovalFields, field => { @@ -1034,6 +1040,8 @@ class BaseOrgRepository extends BaseRepository { * @returns {object} The validation result object. */ validateOrg (org) { + normalizeOrgCveWebsiteUpdateDate(org) + if (!org.authority || (Array.isArray(org.authority) && org.authority.length === 0)) { return { isValid: false, errors: [{ instancePath: '/authority', message: 'authority is required' }] } } diff --git a/src/utils/dateOnly.js b/src/utils/dateOnly.js new file mode 100644 index 000000000..ea96423ef --- /dev/null +++ b/src/utils/dateOnly.js @@ -0,0 +1,68 @@ +const ISO_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})(?:$|T)/ +const ISO_DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/ +const MONGOOSE_DATE_STRING = /^[A-Z][a-z]{2} [A-Z][a-z]{2} \d{2} \d{4} .* GMT[+-]\d{4}/ + +function isValidDateOnlyParts (year, month, day) { + const yearNumber = Number(year) + const monthNumber = Number(month) + const dayNumber = Number(day) + const date = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber)) + + return ( + date.getUTCFullYear() === yearNumber && + date.getUTCMonth() === monthNumber - 1 && + date.getUTCDate() === dayNumber + ) +} + +function isValidDateOnlyString (value) { + if (typeof value !== 'string') return false + const match = value.match(ISO_DATE_ONLY) + if (!match) return false + return isValidDateOnlyParts(match[1], match[2], match[3]) +} + +function normalizeDateOnlyInput (value) { + if (value instanceof Date) { + if (isNaN(value)) return value + return value.toISOString().split('T')[0] + } + + if (typeof value !== 'string') return value + + const match = value.match(ISO_DATE_PREFIX) + if (!match) return value + if (!isValidDateOnlyParts(match[1], match[2], match[3])) return value + + return `${match[1]}-${match[2]}-${match[3]}` +} + +function normalizeDateOnlyOutput (value) { + const normalizedValue = normalizeDateOnlyInput(value) + if (normalizedValue !== value) return normalizedValue + + if (typeof value === 'string' && MONGOOSE_DATE_STRING.test(value)) { + const date = new Date(value) + if (!isNaN(date)) return date.toISOString().split('T')[0] + } + + return value +} + +function normalizeOrgCveWebsiteUpdateDate (org, options = {}) { + if (!org || typeof org !== 'object') return org + if (!org.program_data || typeof org.program_data !== 'object') return org + if (!Object.prototype.hasOwnProperty.call(org.program_data, 'cve_website_update_date')) return org + + const normalize = options.output ? normalizeDateOnlyOutput : normalizeDateOnlyInput + org.program_data.cve_website_update_date = normalize(org.program_data.cve_website_update_date) + + return org +} + +module.exports = { + isValidDateOnlyString, + normalizeDateOnlyInput, + normalizeDateOnlyOutput, + normalizeOrgCveWebsiteUpdateDate +} diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index b64cf16b5..a3f2e54e7 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -109,6 +109,36 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.created.vulnerability_advisory_location_for_web_scraping).to.deep.equal(['https://example.com/scraping']) }) }) + it('Creates a new registry org with a date-only CVE website update date', async () => { + const cveWebsiteUpdateDate = '2024-01-15' + const orgWithWebsiteUpdateDate = { + ...testRegistryOrg, + short_name: 'registry_org_test_web_date', + program_data: { + status: 'active', + cve_website_update_date: cveWebsiteUpdateDate + } + } + + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send(orgWithWebsiteUpdateDate) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body.created).to.haveOwnProperty('program_data') + expect(res.body.created.program_data.cve_website_update_date).to.equal(cveWebsiteUpdateDate) + }) + + await chai.request(app) + .get('/api/registry/org/registry_org_test_web_date') + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.program_data.cve_website_update_date).to.equal(cveWebsiteUpdateDate) + }) + }) }) context('Negative Tests', () => { it('Fails to create a new registry organization with an existing short name', async () => { @@ -149,6 +179,27 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.message).to.equal('Parameters were invalid') }) }) + it('Fails to create a new registry organization with an ambiguous CVE website update date', async () => { + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send({ + ...testRegistryOrg, + short_name: 'test_create_ambiguous_date', + program_data: { + status: 'active', + cve_website_update_date: '01/15/2024' + } + }) + .then((res) => { + expect(res).to.have.status(400) + expect(res.body.message).to.equal('Parameters were invalid') + + const dateError = res.body.errors.find(error => error.instancePath === '/program_data/cve_website_update_date') + expect(dateError).to.not.be.undefined + expect(dateError.message).to.equal('must match format "date"') + }) + }) it('Fails to create a new registry organization with reports_to manually provided', async () => { await chai.request(app) .post('/api/registry/org') @@ -363,6 +414,7 @@ describe('Testing /registryOrg endpoints', () => { }) it('Allows Secretariat to update program_data', async () => { const partnerActiveDate = '2024-01-15' + const cveWebsiteUpdateDate = '2024-04-10T18:30:00.000Z' await chai.request(app) .put('/api/registry/org/registry_org_test') .set(secretariatHeaders) @@ -372,7 +424,8 @@ describe('Testing /registryOrg endpoints', () => { vulnerability_advisory_location_for_web_scraping: ['https://example.com/scraping'], program_data: { status: 'active', - partner_active_date: partnerActiveDate + partner_active_date: partnerActiveDate, + cve_website_update_date: cveWebsiteUpdateDate } }) .then((res, err) => { @@ -382,6 +435,7 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.updated.program_data.status).to.equal('active') expect(res.body.updated.program_data).to.haveOwnProperty('partner_active_date') expect(res.body.updated.program_data.partner_active_date).to.equal(partnerActiveDate) + expect(res.body.updated.program_data.cve_website_update_date).to.equal('2024-04-10') expect(res.body.updated.program_data).to.not.haveOwnProperty('advisory_location_require_credentials') expect(res.body.updated.program_data).to.not.haveOwnProperty('vulnerability_advisory_location_for_web_scraping') expect(res.body.updated.advisory_location_require_credentials).to.be.true