From e90cd17ebc1a1450bfc511675e0ea01a0ec15b32 Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Wed, 15 Jul 2026 14:53:49 +0200 Subject: [PATCH 1/3] feat(CSAF2.1): add mandatoryTest_6_1_14.js --- csaf_2_1/mandatoryTests.js | 2 +- .../mandatoryTests/mandatoryTest_6_1_14.js | 110 ++++++++++++++++++ tests/csaf_2_1/mandatoryTest_6_1_14.js | 27 +++++ tests/csaf_2_1/oasis.js | 1 - 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 csaf_2_1/mandatoryTests/mandatoryTest_6_1_14.js create mode 100644 tests/csaf_2_1/mandatoryTest_6_1_14.js 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/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..cf331ae1 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -79,7 +79,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', From 506a847a230187b037ba4c8cdf147d3c4f0689bf Mon Sep 17 00:00:00 2001 From: rschneider <97682836+rainer-exxcellent@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:27:15 +0200 Subject: [PATCH 2/3] feat(CSAF2.1): use CSAF test results in oasis validatiion tests --- lib/shared/types.ts | 2 +- tests/csaf_2_1/oasis.js | 145 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 134 insertions(+), 13 deletions(-) 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/oasis.js b/tests/csaf_2_1/oasis.js index cf331ae1..67643e69 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -81,11 +81,15 @@ const skippedTests = new Set([ 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-03-02.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 */ +/** @typedef {import('../../lib/shared/types.js').TestResult} TestResult */ /** @typedef {Map} TestMap */ @@ -94,6 +98,34 @@ const skippedTests = new Set([ * @property {TestCase[]} tests */ +/** + * @typedef {object} CsafTestResult + * @property {string} $schema + * @property {boolean} overall_valid + * @property {PrimaryResult} primary_result + * @property {string} resultschema_version + * @property {SecondaryResult[]} secondary_results + */ + +/** + * @typedef {object} PrimaryResult + * @property {string} id + * @property {boolean} passed + */ + +/** + * @typedef {object} SecondaryResult + * @property {string} id + * @property {boolean} passed + * @property {ResultError[]} [errors] + */ + +/** + * @typedef {object} ResultError + * @property {string} instance_path + * @property {string} message + */ + /** * @typedef {object} TestCase * @property {string} id @@ -105,6 +137,7 @@ const skippedTests = new Set([ /** * @typedef {object} TestSpec * @property {string} name + * @property {string} result * @property {boolean} valid */ @@ -133,6 +166,64 @@ const testCases = /** @type {TestCases} */ ( const testMap = parseTestCases() +/** + * + * @param {SecondaryResult[]} secondaryResults + * @param {string} group + * @param doc {any} CSAF document to check + * @return {Promise>} + */ +async function executeSecondLevelTests(secondaryResults, group, doc) { + let testId2secondaryExecutionResult = new Map() + if (secondaryResults) { + const secondaryResultIds = secondaryResults.map((result) => result.id) + for (const secondaryResultId of secondaryResultIds) { + const secondaryTest = tests + .get(group) + ?.get(`${group}Test_${secondaryResultId.replace(/\./g, '_')}`) + if (secondaryTest) { + const resultSecondaryTestResult = await secondaryTest(doc) + testId2secondaryExecutionResult.set( + secondaryResultId, + resultSecondaryTestResult + ) + } else { + assert.fail(`Secondary test not implemented {${secondaryResultId}}`) + } + } + } + return testId2secondaryExecutionResult +} +/** + * Validate the defined test result against the execution results + * @param {CsafTestResult} csafTestResult + * @param {TestResult} primaryExecutionResult + * @param {Map} testId2secondaryExecutionResult + */ + +function validateTestResults( + csafTestResult, + primaryExecutionResult, + testId2secondaryExecutionResult +) { + assert.equal( + primaryExecutionResult.isValid, + csafTestResult.primary_result.passed + ) + if (csafTestResult.secondary_results) { + for (const secondaryResult of csafTestResult.secondary_results) { + const executionResult = testId2secondaryExecutionResult.get( + secondaryResult.id + ) + if (executionResult) { + assert.equal(executionResult.isValid, secondaryResult.passed) + } else { + assert.fail('No executionResult found') + } + } + } +} + for (const [group, t] of testMap) { describe(group, function () { for (const [testId, u] of t) { @@ -140,8 +231,12 @@ for (const [group, t] of testMap) { for (const [type, testSpecs] of u) { describe(type, function () { for (const testSpec of testSpecs) { - if (skippedTests.has(testSpec.name)) continue - if (excluded.includes(testId)) continue + if (skippedTests.has(testSpec.name)) { + continue + } + if (excluded.includes(testId)) { + continue + } it(testSpec.name, async () => { const test = tests @@ -154,35 +249,61 @@ for (const [group, t] of testMap) { readFileSync(new URL(testSpec.name, testDataBaseUrl), 'utf-8') ) - const result = await test(doc) + /** @type {CsafTestResult | null} */ + let csafTestResult = null + if (testSpec.result) { + csafTestResult = JSON.parse( + readFileSync( + new URL(testSpec.result, testDataBaseUrl), + 'utf-8' + ) + ) + } - if (group === 'mandatory') { - assert.equal(result.isValid, testSpec.valid) + /** @type {TestResult} */ + let primaryExecutionResult = await test(doc) + if (csafTestResult) { + let testId2secondaryExecutionResult = + await executeSecondLevelTests( + csafTestResult.secondary_results, + group, + doc + ) + validateTestResults( + csafTestResult, + primaryExecutionResult, + testId2secondaryExecutionResult + ) + } else if (group === 'mandatory') { + assert.equal(primaryExecutionResult.isValid, testSpec.valid) assert.equal( - Boolean(result.errors?.length), + Boolean(primaryExecutionResult.errors?.length), type === 'failures', type === 'failures' ? 'should have errors' - : `should not have errors, but had ${result.errors?.length}` + : `should not have errors, but had ${primaryExecutionResult.errors?.length}` ) } else { - assert.equal(result.isValid === undefined, testSpec.valid) + assert.equal( + primaryExecutionResult.isValid === undefined, + testSpec.valid + ) if (group === 'recommended') { assert.equal( - Boolean(result.warnings?.length), + Boolean(primaryExecutionResult.warnings?.length), type === 'failures', type === 'failures' ? 'should have warnings' - : `should not have warnings, but had ${result.warnings?.length}` + : `should not have warnings, but had ${primaryExecutionResult.warnings?.length}` ) } else if (group === 'informative') { assert.equal( - Boolean(result.infos?.length), + Boolean(primaryExecutionResult.infos?.length), type === 'failures', type === 'failures' ? 'should have infos' - : `should not have infos, but had ${result.infos?.length}` + : `should not have infos, but had ${primaryExecutionResult.infos?.length}` ) } } From 13102ce489009fd65f3b0cbac478e188c732954e Mon Sep 17 00:00:00 2001 From: rschneider <97682836+rainer-exxcellent@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:09:32 +0200 Subject: [PATCH 3/3] feat(CSAF2.1): #664 use valid information for the corresponding test --- tests/csaf_2_1/oasis.js | 168 +++++++--------------------------------- 1 file changed, 27 insertions(+), 141 deletions(-) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 67643e69..814c8280 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -89,7 +89,6 @@ const skippedTests = new Set([ ]) /** @typedef {import('../../lib/shared/types.js').DocumentTest} DocumentTest */ -/** @typedef {import('../../lib/shared/types.js').TestResult} TestResult */ /** @typedef {Map} TestMap */ @@ -98,34 +97,6 @@ const skippedTests = new Set([ * @property {TestCase[]} tests */ -/** - * @typedef {object} CsafTestResult - * @property {string} $schema - * @property {boolean} overall_valid - * @property {PrimaryResult} primary_result - * @property {string} resultschema_version - * @property {SecondaryResult[]} secondary_results - */ - -/** - * @typedef {object} PrimaryResult - * @property {string} id - * @property {boolean} passed - */ - -/** - * @typedef {object} SecondaryResult - * @property {string} id - * @property {boolean} passed - * @property {ResultError[]} [errors] - */ - -/** - * @typedef {object} ResultError - * @property {string} instance_path - * @property {string} message - */ - /** * @typedef {object} TestCase * @property {string} id @@ -137,10 +108,12 @@ const skippedTests = new Set([ /** * @typedef {object} TestSpec * @property {string} name - * @property {string} result - * @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', @@ -166,64 +139,6 @@ const testCases = /** @type {TestCases} */ ( const testMap = parseTestCases() -/** - * - * @param {SecondaryResult[]} secondaryResults - * @param {string} group - * @param doc {any} CSAF document to check - * @return {Promise>} - */ -async function executeSecondLevelTests(secondaryResults, group, doc) { - let testId2secondaryExecutionResult = new Map() - if (secondaryResults) { - const secondaryResultIds = secondaryResults.map((result) => result.id) - for (const secondaryResultId of secondaryResultIds) { - const secondaryTest = tests - .get(group) - ?.get(`${group}Test_${secondaryResultId.replace(/\./g, '_')}`) - if (secondaryTest) { - const resultSecondaryTestResult = await secondaryTest(doc) - testId2secondaryExecutionResult.set( - secondaryResultId, - resultSecondaryTestResult - ) - } else { - assert.fail(`Secondary test not implemented {${secondaryResultId}}`) - } - } - } - return testId2secondaryExecutionResult -} -/** - * Validate the defined test result against the execution results - * @param {CsafTestResult} csafTestResult - * @param {TestResult} primaryExecutionResult - * @param {Map} testId2secondaryExecutionResult - */ - -function validateTestResults( - csafTestResult, - primaryExecutionResult, - testId2secondaryExecutionResult -) { - assert.equal( - primaryExecutionResult.isValid, - csafTestResult.primary_result.passed - ) - if (csafTestResult.secondary_results) { - for (const secondaryResult of csafTestResult.secondary_results) { - const executionResult = testId2secondaryExecutionResult.get( - secondaryResult.id - ) - if (executionResult) { - assert.equal(executionResult.isValid, secondaryResult.passed) - } else { - assert.fail('No executionResult found') - } - } - } -} - for (const [group, t] of testMap) { describe(group, function () { for (const [testId, u] of t) { @@ -231,79 +146,50 @@ for (const [group, t] of testMap) { for (const [type, testSpecs] of u) { describe(type, function () { for (const testSpec of testSpecs) { - if (skippedTests.has(testSpec.name)) { - continue - } - if (excluded.includes(testId)) { - continue - } + if (skippedTests.has(testSpec.name)) continue + 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') ) - /** @type {CsafTestResult | null} */ - let csafTestResult = null - if (testSpec.result) { - csafTestResult = JSON.parse( - readFileSync( - new URL(testSpec.result, testDataBaseUrl), - 'utf-8' - ) - ) - } + const result = await testToExecute(doc) - /** @type {TestResult} */ - let primaryExecutionResult = await test(doc) - if (csafTestResult) { - let testId2secondaryExecutionResult = - await executeSecondLevelTests( - csafTestResult.secondary_results, - group, - doc - ) - validateTestResults( - csafTestResult, - primaryExecutionResult, - testId2secondaryExecutionResult - ) - } else if (group === 'mandatory') { - assert.equal(primaryExecutionResult.isValid, testSpec.valid) + if (group === 'mandatory') { + const validForCurrentTest = type === TYPE_VALID + assert.equal(result.isValid, validForCurrentTest) assert.equal( - Boolean(primaryExecutionResult.errors?.length), - type === 'failures', - type === 'failures' + Boolean(result.errors?.length), + type === TYPE_FAILURES, + type === TYPE_FAILURES ? 'should have errors' - : `should not have errors, but had ${primaryExecutionResult.errors?.length}` + : `should not have errors, but had ${result.errors?.length}` ) } else { - assert.equal( - primaryExecutionResult.isValid === undefined, - testSpec.valid - ) + assert.equal(result.isValid === undefined, testSpec.valid) if (group === 'recommended') { assert.equal( - Boolean(primaryExecutionResult.warnings?.length), - type === 'failures', - type === 'failures' + Boolean(result.warnings?.length), + type === TYPE_FAILURES, + type === TYPE_FAILURES ? 'should have warnings' - : `should not have warnings, but had ${primaryExecutionResult.warnings?.length}` + : `should not have warnings, but had ${result.warnings?.length}` ) } else if (group === 'informative') { assert.equal( - Boolean(primaryExecutionResult.infos?.length), - type === 'failures', - type === 'failures' + Boolean(result.infos?.length), + type === TYPE_FAILURES, + type === TYPE_FAILURES ? 'should have infos' - : `should not have infos, but had ${primaryExecutionResult.infos?.length}` + : `should not have infos, but had ${result.infos?.length}` ) } } @@ -336,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) ) ) }