diff --git a/README.md b/README.md index 6f4f9297..4b09ef63 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,8 @@ export const recommendedTest_6_2_17: DocumentTest export const recommendedTest_6_2_18: DocumentTest export const recommendedTest_6_2_22: DocumentTest export const recommendedTest_6_2_23: DocumentTest +export const recommendedTest_6_2_39_2: DocumentTest +export const recommendedTest_6_2_39_4: DocumentTest ``` [(back to top)](#bsi-csaf-validator-lib) diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index a39c6673..c9b7ab2a 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -32,3 +32,5 @@ export { recommendedTest_6_2_27 } from './recommendedTests/recommendedTest_6_2_2 export { recommendedTest_6_2_28 } from './recommendedTests/recommendedTest_6_2_28.js' export { recommendedTest_6_2_29 } from './recommendedTests/recommendedTest_6_2_29.js' export { recommendedTest_6_2_38 } from './recommendedTests/recommendedTest_6_2_38.js' +export { recommendedTest_6_2_39_2 } from './recommendedTests/recommendedTest_6_2_39_2.js' +export { recommendedTest_6_2_39_4 } from './recommendedTests/recommendedTest_6_2_39_4.js' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_39_2.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_39_2.js new file mode 100644 index 00000000..11f4f2f5 --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_39_2.js @@ -0,0 +1,105 @@ +import Ajv from 'ajv/dist/jtd.js' +import { + containsOneNoteWithTitleAndCategory, + getTranslationInDocumentLang, + isLangSpecifiedAndNotEnglish, +} from '../../lib/shared/languageSpecificTranslation.js' + +const ajv = new Ajv() + +/* + This is the jtd schema that needs to match the input document so that the + test is activated. If this schema doesn't match it normally means that the input + document does not validate against the csaf json schema or optional fields that + the test checks are not present. + */ +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + properties: { + category: { type: 'string' }, + }, + optionalProperties: { + lang: { + type: 'string', + }, + notes: { + elements: { + additionalProperties: true, + optionalProperties: { + category: { + type: 'string', + }, + title: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, +}) + +const validateSchema = ajv.compile(inputSchema) + +/** + * If the document language is specified but not English, it MUST be tested that exactly one item in document + * notes exists that has the language specific translation of the term Reasoning for Withdrawal as title. + * The category of this item MUST be description. If no language-specific translation has been recorded, + * the test MUST be skipped and output an information to the user that no such translation is known. + * + * @param {unknown} doc + */ +export function recommendedTest_6_2_39_2(doc) { + /* + The `ctx` variable holds the state that is accumulated during the test run and is + finally returned by the function. + */ + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + const noteCategory = 'description' + + if (!validateSchema(doc) || doc.document.category !== 'csaf_withdrawn') { + return ctx + } + + const withdrawalInDocLang = getTranslationInDocumentLang( + doc, + 'reasoning_for_withdrawal' + ) + if (!withdrawalInDocLang) { + ctx.warnings.push({ + instancePath: '/document/notes', + message: + 'no language specific translation for "Reasoning for Withdrawal" has been recorded', + }) + return ctx + } + + if (isLangSpecifiedAndNotEnglish(doc.document.lang)) { + const notes = doc.document.notes + if ( + !notes || + !containsOneNoteWithTitleAndCategory( + notes, + withdrawalInDocLang, + noteCategory + ) + ) { + ctx.warnings.push({ + instancePath: '/document/notes', + message: + `for document category "csaf_withdrawn" exactly one note must exist ` + + `with note category "${noteCategory}" and title "${withdrawalInDocLang}`, + }) + } + } + + return ctx +} diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_39_4.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_39_4.js new file mode 100644 index 00000000..4e0677a3 --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_39_4.js @@ -0,0 +1,105 @@ +import Ajv from 'ajv/dist/jtd.js' +import { + existsReferenceWithSummaryAndCategory, + getTranslationInDocumentLang, + isLangSpecifiedAndNotEnglish, +} from '../../lib/shared/languageSpecificTranslation.js' + +const ajv = new Ajv() + +/* + This is the jtd schema that needs to match the input document so that the + test is activated. If this schema doesn't match it normally means that the input + document does not validate against the csaf json schema or optional fields that + the test checks are not present. + */ +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + properties: { + category: { type: 'string' }, + }, + optionalProperties: { + lang: { + type: 'string', + }, + references: { + elements: { + additionalProperties: true, + optionalProperties: { + category: { + type: 'string', + }, + references: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, +}) + +const validateSchema = ajv.compile(inputSchema) + +/** + * If the document language is specified but not English, it MUST be tested that at least one item in document + * references exists that starts with the language-specific translation of the term Superseding Document as summary. + * The category of this item MUST be external. If no language-specific translation has been recorded, + * the test MUST be skipped and output an information to the user that no such translation is known. + * + * @param {unknown} doc + */ +export function recommendedTest_6_2_39_4(doc) { + /* + The `ctx` variable holds the state that is accumulated during the test run and is + finally returned by the function. + */ + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + const referenceCategory = 'external' + + if (!validateSchema(doc) || doc.document.category !== 'csaf_superseded') { + return ctx + } + + const supersedingInDocLang = getTranslationInDocumentLang( + doc, + 'superseding_document' + ) + if (!supersedingInDocLang) { + ctx.warnings.push({ + instancePath: '/document/references', + message: + 'no language specific translation for "Superseding Document" has been recorded', + }) + return ctx + } + + if (isLangSpecifiedAndNotEnglish(doc.document.lang)) { + const references = doc.document.references + if ( + !references || + !existsReferenceWithSummaryAndCategory( + references, + supersedingInDocLang, + referenceCategory + ) + ) { + ctx.warnings.push({ + instancePath: '/document/references', + message: + `for document category "csaf_superseded" at least one references must exist ` + + `with reference category "${referenceCategory}" and whose summary begins with ${supersedingInDocLang}`, + }) + } + } + + return ctx +} diff --git a/lib/language_specific_translation/translations.js b/lib/language_specific_translation/translations.js new file mode 100644 index 00000000..a9446592 --- /dev/null +++ b/lib/language_specific_translation/translations.js @@ -0,0 +1,17 @@ +/** + * JavaScript version of JSON file: csaf_2.1/language_specific_translation/translations.json + */ +export default { + $schema: + 'https://raw.githubusercontent.com/oasis-tcs/csaf/master/csaf_2.1/test/language_specific_translation/translations_json_schema.json', + translation_version: '2.1', + translation: { + de: { + license: 'Lizenz', + product_description: 'Produktbeschreibung', + reasoning_for_supersession: 'Begründung für die Ersetzung', + reasoning_for_withdrawal: 'Begründung für die Zurückziehung', + superseding_document: 'Ersetzendes Dokument', + }, + }, +} diff --git a/lib/shared/languageSpecificTranslation.js b/lib/shared/languageSpecificTranslation.js new file mode 100644 index 00000000..b3178185 --- /dev/null +++ b/lib/shared/languageSpecificTranslation.js @@ -0,0 +1,77 @@ +/** + * Checks if the document language is specified and not English + * + * @param {string | undefined} language - The language expression to check + * @returns {boolean} True if the language is valid, false otherwise + */ +export function isLangSpecifiedAndNotEnglish(language) { + return ( + !!language && !(bcp47.parse(language)?.langtag.language.language === 'en') + ) +} +import bcp47 from 'bcp47' +import translations from '../../lib/language_specific_translation/translations.js' + +/** + * test whether exactly one item in document notes exists that has the given title. + * and the given category. + * @param {({} & { category?: string | undefined; title?: string | undefined; } & Record)[]} notes + * @param {string} titleToFind + * @param {string} category + * @returns {boolean} True if the language is valid, false otherwise + */ +export function containsOneNoteWithTitleAndCategory( + notes, + titleToFind, + category +) { + return ( + notes.filter( + (note) => note.category === category && note.title === titleToFind + ).length === 1 + ) +} + +/** + * test whether at least one item in document references exists that starts with the given summary + * and has the given category. + * @param {({} & { category?: string | undefined; summary?: string | undefined; } & Record)[]} references + * @param {string} summaryStartsWith + * @param {string} category + * @returns {boolean} True if the reference was found, false otherwise + */ +export function existsReferenceWithSummaryAndCategory( + references, + summaryStartsWith, + category +) { + return ( + references.filter( + (reference) => + reference.category === category && + reference.summary && + reference.summary.startsWith(summaryStartsWith) + ).length > 0 + ) +} + +/** + * Get the language specific translation of the given i18nKey + * @param {{ document: { lang?: string; }; }} doc + * @param {string} i18nKey + * @return {string | undefined} + */ +export function getTranslationInDocumentLang(doc, i18nKey) { + if (!doc.document.lang) { + return undefined + } + const language = bcp47.parse(doc.document.lang)?.langtag.language.language + + /** @type {Record>}*/ + const translationByLang = translations.translation + if (!language || !translationByLang[language]) { + return undefined + } else { + return translationByLang[language][i18nKey] + } +} diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 0e9d2e60..1774fece 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -48,9 +48,7 @@ const excluded = [ '6.2.36', '6.2.37', '6.2.39.1', - '6.2.39.2', '6.2.39.3', - '6.2.39.4', '6.2.40', '6.2.41', '6.2.42', diff --git a/tests/csaf_2_1/recommendedTest_6_2_39_2.js b/tests/csaf_2_1/recommendedTest_6_2_39_2.js new file mode 100644 index 00000000..52c54fc2 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_39_2.js @@ -0,0 +1,37 @@ +import { recommendedTest_6_2_39_2 } from '../../csaf_2_1/recommendedTests/recommendedTest_6_2_39_2.js' +import { expect } from 'chai' +import assert from 'node:assert' +import { getTranslationInDocumentLang } from '../../lib/shared/languageSpecificTranslation.js' + +describe('recommendedTest_6_2_39_2', function () { + it('only runs on relevant documents', function () { + assert.equal(recommendedTest_6_2_39_2({}).warnings.length, 0) + }) + + it('only runs on valid language', function () { + assert.equal( + recommendedTest_6_2_39_2({ + document: { lang: '123', license_expression: 'MIT' }, + }).warnings.length, + 0 + ) + }) + + it('check get ReasoningForWithdrawal in document lang', function () { + expect( + getTranslationInDocumentLang( + { document: { lang: 'de' } }, + 'reasoning_for_withdrawal' + ) + ).to.eq('Begründung für die Zurückziehung') + expect( + getTranslationInDocumentLang( + { document: { lang: 'es' } }, + 'reasoning_for_withdrawal' + ) + ).to.eq(undefined) + expect( + getTranslationInDocumentLang({ document: {} }, 'reasoning_for_withdrawal') + ).to.eq(undefined) + }) +}) diff --git a/tests/csaf_2_1/recommendedTest_6_2_39_4.js b/tests/csaf_2_1/recommendedTest_6_2_39_4.js new file mode 100644 index 00000000..39af630c --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_39_4.js @@ -0,0 +1,34 @@ +import { recommendedTest_6_2_39_4 } from '../../csaf_2_1/recommendedTests/recommendedTest_6_2_39_4.js' +import { expect } from 'chai' +import assert from 'node:assert' +import { getTranslationInDocumentLang } from '../../lib/shared/languageSpecificTranslation.js' + +describe('recommendedTest_6_2_39_4', function () { + it('only runs on relevant documents', function () { + assert.equal(recommendedTest_6_2_39_4({}).warnings.length, 0) + }) + + it('only runs on valid language', function () { + assert.equal( + recommendedTest_6_2_39_4({ + document: { lang: '123', license_expression: 'MIT' }, + }).warnings.length, + 0 + ) + }) + + it('check get superseding_document in document lang', function () { + expect( + getTranslationInDocumentLang( + { document: { lang: 'de' } }, + 'superseding_document' + ) + ).to.eq('Ersetzendes Dokument') + expect( + getTranslationInDocumentLang({ document: { lang: 'es' } }, 'v') + ).to.eq(undefined) + expect( + getTranslationInDocumentLang({ document: {} }, 'superseding_document') + ).to.eq(undefined) + }) +})