diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index 821d3613..9fd32bea 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..a717e914 --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_14.js @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' + +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 () { + assert.equal(mandatoryTest_6_1_14({ product_tree: 'mydoc' }).isValid, 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' }, + ], + }, + }, + } + + assert.doesNotThrow(() => mandatoryTest_6_1_14(doc)) + const result = mandatoryTest_6_1_14(doc) + assert.equal(result.isValid, true) + assert.deepEqual(result.errors, []) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 1521905f..814c8280 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -79,11 +79,13 @@ 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-11.json', + 'recommended/oasis_csaf_tc-csaf_2_1-2024-6-2-38-12.json', 'recommended/oasis_csaf_tc-csaf_2_1-2024-6-2-38-13.json', 'recommended/oasis_csaf_tc-csaf_2_1-2024-6-2-38-02.json', + 'recommended/oasis_csaf_tc-csaf_2_1-2024-6-2-38-01.json', ]) /** @typedef {import('../../lib/shared/types.js').DocumentTest} DocumentTest */ @@ -106,9 +108,12 @@ const skippedTests = new Set([ /** * @typedef {object} TestSpec * @property {string} name - * @property {boolean} valid + * @property {boolean} valid // The valid field indicates whether the document is valid against all basic tests */ +const TYPE_FAILURES = 'failures' +const TYPE_VALID = 'valid' //The entry in the array indicates that the test case is valid against the corresponding test. + const tests = new Map([ [ 'informative', @@ -145,24 +150,25 @@ for (const [group, t] of testMap) { if (excluded.includes(testId)) continue it(testSpec.name, async () => { - const test = tests + const testToExecute = tests .get(group) ?.get(`${group}Test_${testId.replace(/\./g, '_')}`) - assert(test, 'test does not exist') + assert(testToExecute, 'test does not exist') const doc = JSON.parse( readFileSync(new URL(testSpec.name, testDataBaseUrl), 'utf-8') ) - const result = await test(doc) + const result = await testToExecute(doc) if (group === 'mandatory') { - assert.equal(result.isValid, testSpec.valid) + const validForCurrentTest = type === TYPE_VALID + assert.equal(result.isValid, validForCurrentTest) assert.equal( Boolean(result.errors?.length), - type === 'failures', - type === 'failures' + type === TYPE_FAILURES, + type === TYPE_FAILURES ? 'should have errors' : `should not have errors, but had ${result.errors?.length}` ) @@ -172,16 +178,16 @@ for (const [group, t] of testMap) { if (group === 'recommended') { assert.equal( Boolean(result.warnings?.length), - type === 'failures', - type === 'failures' + type === TYPE_FAILURES, + type === TYPE_FAILURES ? 'should have warnings' : `should not have warnings, but had ${result.warnings?.length}` ) } else if (group === 'informative') { assert.equal( Boolean(result.infos?.length), - type === 'failures', - type === 'failures' + type === TYPE_FAILURES, + type === TYPE_FAILURES ? 'should have infos' : `should not have infos, but had ${result.infos?.length}` ) @@ -216,8 +222,8 @@ function parseTestCases() { new Map(testData.get(test.group)).set( test.id, new Map(testData.get(test.group)?.get(test.id)) - .set('valid', valids) - .set('failures', failures) + .set(TYPE_VALID, valids) + .set(TYPE_FAILURES, failures) ) ) }