diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index 3d89ae65..c7b8dc5e 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -2,7 +2,6 @@ export { mandatoryTest_6_1_3, mandatoryTest_6_1_5, mandatoryTest_6_1_12, - mandatoryTest_6_1_14, mandatoryTest_6_1_15, mandatoryTest_6_1_16, mandatoryTest_6_1_17, @@ -38,6 +37,7 @@ export { mandatoryTest_6_1_9 } from './mandatoryTests/mandatoryTest_6_1_9.js' export { mandatoryTest_6_1_10 } from './mandatoryTests/mandatoryTest_6_1_10.js' export { mandatoryTest_6_1_11 } from './mandatoryTests/mandatoryTest_6_1_11.js' export { mandatoryTest_6_1_13 } from './mandatoryTests/mandatoryTest_6_1_13.js' +export { mandatoryTest_6_1_14 } from './mandatoryTests/mandatoryTest_6_1_14.js' export { mandatoryTest_6_1_27_3 } from './mandatoryTests/mandatoryTest_6_1_27_3.js' export { mandatoryTest_6_1_27_4 } from './mandatoryTests/mandatoryTest_6_1_27_4.js' export { mandatoryTest_6_1_27_5 } from './mandatoryTests/mandatoryTest_6_1_27_5.js' diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_14.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_14.js new file mode 100644 index 00000000..705a9bb4 --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_14.js @@ -0,0 +1,110 @@ +import semver from 'semver' +import { Ajv } from 'ajv/dist/jtd.js' +import { compareZonedDateTimes } from '../dateHelper.js' + +const { gt, valid } = semver + +const ajv = new Ajv() + +const revisionHistoryEntrySchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + date: { type: 'string' }, + number: { type: 'string' }, + }, +}) + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + document: { + additionalProperties: true, + optionalProperties: { + tracking: { + additionalProperties: true, + optionalProperties: { + revision_history: { + elements: revisionHistoryEntrySchema, + }, + }, + }, + }, + }, + }, +}) + +const validate = ajv.compile(inputSchema) + +/** + * @typedef {import('ajv/dist/core.js').JTDDataType} RevisionHistoryEntry + */ + +/** + * Maps `number` to a value comparable with semver. Integer version numbers + * are mapped to semantic versioning by appending `.0.0`. + * + * @param {string} number + * @returns {string | null} + */ +const toComparableSemver = (number) => { + if (valid(number)) return number + return /^\d+$/.test(number) ? `${number}.0.0` : null +} + +/** + * @param {RevisionHistoryEntry} a + * @param {RevisionHistoryEntry} b + * @returns {number} + */ +const compareEntries = (a, b) => { + const dateComparison = compareZonedDateTimes(a.date, b.date) + if (dateComparison !== 0) { + return dateComparison + } + + const aVersion = toComparableSemver(a.number) + const bVersion = toComparableSemver(b.number) + if (aVersion === null || bVersion === null) { + return 0 + } + return semver.compare(aVersion, bVersion) +} + +/** + * @param {unknown} doc + */ +export function mandatoryTest_6_1_14(doc) { + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + if ( + !validate(doc) || + !Array.isArray(doc.document?.tracking?.revision_history) + ) { + return ctx + } + + const sortedNumbers = doc.document.tracking.revision_history + .slice() + .sort(compareEntries) + .map((entry) => toComparableSemver(entry.number)) + .filter(/** @returns {n is string} */ (n) => n !== null) + + const isAscending = sortedNumbers.every( + (number, index, all) => index === 0 || gt(number, all[index - 1]) + ) + + if (!isAscending) { + ctx.isValid = false + ctx.errors.push({ + instancePath: `/document/tracking/revision_history`, + message: + 'items must be in ascending order when sorted by "date" and "number"', + }) + } + + return ctx +} diff --git a/lib/shared/types.ts b/lib/shared/types.ts index 59d813ab..6d4ca5e8 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -5,7 +5,7 @@ export interface Result { infos: Array<{ message: string; instancePath: string }> } -interface TestResult { +export interface TestResult { isValid?: boolean warnings?: Array<{ message: string; instancePath: string }> errors?: Array<{ message: string; instancePath: string }> diff --git a/tests/csaf_2_1/mandatoryTest_6_1_14.js b/tests/csaf_2_1/mandatoryTest_6_1_14.js new file mode 100644 index 00000000..9c4d4049 --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_14.js @@ -0,0 +1,27 @@ +import { mandatoryTest_6_1_14 } from '../../csaf_2_1/mandatoryTests/mandatoryTest_6_1_14.js' + +describe('mandatoryTest_6_1_14', function () { + it('only runs on relevant documents', function () { + expect(mandatoryTest_6_1_14({ product_tree: 'mydoc' }).isValid).to.equal( + true + ) + }) + + it('skips documents with invalid revision_history entries', function () { + const doc = { + document: { + tracking: { + revision_history: [ + { date: '2020-01-01T00:00:00+00:00', number: '1.0.0' }, + { date: '2020-01-01T00:00:00+00:00', number: 'invalid' }, + ], + }, + }, + } + + expect(() => mandatoryTest_6_1_14(doc)).toBeTruthy() + const result = mandatoryTest_6_1_14(doc) + expect(result.isValid).to.equal(true) + expect(result.errors).to.deep.equal([]) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index e372906b..07e2106a 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -68,7 +68,6 @@ const excluded = [ const skippedTests = new Set([ 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-03-01.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-03-02.json', - 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-14-32.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-21-17.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-27-08-02.json', 'recommended/oasis_csaf_tc-csaf_2_1-2024-6-2-38-13.json',