From cd12da748b3af6115d54b31b85f97ab1f53be583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Burgd=C3=B6rfer?= Date: Tue, 2 Sep 2025 14:01:05 +0200 Subject: [PATCH] feat: add informative test 6.3.16 --- .github/workflows/run-tests.yml | 8 + DEVELOPMENT.md | 7 + README.md | 10 +- compose.yml | 5 + context.js | 12 ++ csaf_2_1/informativeTests.js | 1 + .../informativeTest_6_3_16.js | 198 ++++++++++++++++++ scripts/test.js | 26 ++- tests/csaf_2_1/informativeTest_6_3_16.js | 21 ++ tests/csaf_2_1/oasis.js | 1 - 10 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 compose.yml create mode 100644 context.js create mode 100644 csaf_2_1/informativeTests/informativeTest_6_3_16.js create mode 100644 tests/csaf_2_1/informativeTest_6_3_16.js diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 065428c7..df098edd 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -22,6 +22,14 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + - name: Start language tool + run: docker compose up -d + - name: Wait for app start + uses: ifaxity/wait-on-action@v1 + with: + delay: 1 + timeout: 30000 + resource: tcp:localhost:8010 - run: npm ci - run: npm run test-report - run: npm run test-coverage-lcov diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3718d04a..fdf6a7ea 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,10 +2,17 @@ ## Table of Contents +- [Language Tool](#language-tool) - [Code Style](#code-style) - [Formatting with prettier](#formatting-with-prettier) - [Quoting Strings](#quoting-strings) +## Language Tool + +The informative test 6.3.16 needs a running languagetool server. To set one for development you can use the `compose.yml` provided with the repository: + + docker compose up -d + ## Code Style ### Formatting with prettier diff --git a/README.md b/README.md index ed6bd63f..24718300 100644 --- a/README.md +++ b/README.md @@ -359,11 +359,10 @@ The following tests are not yet implemented and therefore missing: **Informative Tests** -- Informative Test 6.2.13 -- Informative Test 6.2.14 -- Informative Test 6.2.15 -- Informative Test 6.2.16 -- Informative Test 6.2.17 +- Informative Test 6.3.13 +- Informative Test 6.3.14 +- Informative Test 6.3.15 +- Informative Test 6.3.17 #### Module `csaf_2_1/schemaTests.js` @@ -480,6 +479,7 @@ export const informativeTest_6_3_9: DocumentTest export const informativeTest_6_3_10: DocumentTest export const informativeTest_6_3_11: DocumentTest export const informativeTest_6_3_12: DocumentTest +export const informativeTest_6_3_16: DocumentTest ``` [(back to top)](#bsi-csaf-validator-lib) diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..51ef72a1 --- /dev/null +++ b/compose.yml @@ -0,0 +1,5 @@ +services: + languagetool: + image: collabora/languagetool + ports: + - 8010:8010 diff --git a/context.js b/context.js new file mode 100644 index 00000000..01a6d846 --- /dev/null +++ b/context.js @@ -0,0 +1,12 @@ +/** + * @typedef {object} Context + * @property {string} languageToolUrl The url to the language tool + */ + +/** + * This is the context that is used to execute the tests. Modify it when + * initializing the library to change settings. + * + * @type {Context} + */ +export const context = { languageToolUrl: 'http://localhost:8010' } diff --git a/csaf_2_1/informativeTests.js b/csaf_2_1/informativeTests.js index b3142316..a475276e 100644 --- a/csaf_2_1/informativeTests.js +++ b/csaf_2_1/informativeTests.js @@ -12,3 +12,4 @@ export { informativeTest_6_3_1 } from './informativeTests/informativeTest_6_3_1. export { informativeTest_6_3_2 } from './informativeTests/informativeTest_6_3_2.js' export { informativeTest_6_3_4 } from './informativeTests/informativeTest_6_3_4.js' export { informativeTest_6_3_12 } from './informativeTests/informativeTest_6_3_12.js' +export { informativeTest_6_3_16 } from './informativeTests/informativeTest_6_3_16.js' diff --git a/csaf_2_1/informativeTests/informativeTest_6_3_16.js b/csaf_2_1/informativeTests/informativeTest_6_3_16.js new file mode 100644 index 00000000..f023a44e --- /dev/null +++ b/csaf_2_1/informativeTests/informativeTest_6_3_16.js @@ -0,0 +1,198 @@ +/* + This test depends on the languagetool server to be available. See + https://languagetool.org/de. A `compose.yml` file is available in the + repository root to start an instance. + */ + +import Ajv from 'ajv/dist/jtd.js' +import bcp47 from 'bcp47' +import { context } from '../../context.js' + +const ajv = new Ajv() + +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + document: { + additionalProperties: true, + optionalProperties: { + lang: { type: 'string' }, + }, + }, + }, +}) + +const validateInput = ajv.compile(inputSchema) + +/** + * If the document language is given it MUST be tested that a grammar check for + * the given language does not find any mistakes. The test SHALL be skipped if + * the document language is not set. It SHALL fail if the given language is not + * supported. + * + * @param {unknown} doc + * @returns + */ +export async function informativeTest_6_3_16(doc) { + const ctx = { + infos: /** @type {Array<{ message: string; instancePath: string }>} */ ([]), + } + + if (!validateInput(doc)) { + return ctx + } + + const lang = + (doc.document?.lang && + bcp47.parse(doc.document.lang)?.langtag.language.language) ?? + 'en' + + /* + Check if the language is supported by the languagetool server. + */ + { + /** + * @typedef {object} Language + * @property {string} code + */ + + /** @typedef {Language[]} Response */ + + const res = await fetch(new URL('/v2/languages', context.languageToolUrl), { + headers: { + accept: 'application/json', + }, + }) + if (!res.ok) throw new Error('request to languagetool failed') + + const json = /** @type {Response} */ (await res.json()) + + if (!json.some((l) => l.code === lang)) { + ctx.infos.push({ + instancePath: '/document/lang', + message: 'language is not supported', + }) + } + } + + for (const path of [ + '/document/acknowledgments[]/summary', + '/document/aggregate_severity/text', + '/document/distribution/text', + '/document/notes[]/audience', + '/document/notes[]/text', + '/document/notes[]/title', + '/document/publisher/issuing_authority', + '/document/references[]/summary', + '/document/title', + '/document/tracking/revision_history[]/summary', + '/product_tree/product_groups[]/summary', + '/vulnerabilities[]/acknowledgments[]/summary', + '/vulnerabilities[]/involvements[]/summary', + '/vulnerabilities[]/notes[]/audience', + '/vulnerabilities[]/notes[]/text', + '/vulnerabilities[]/notes[]/title', + '/vulnerabilities[]/references[]/summary', + '/vulnerabilities[]/remediations[]/details', + '/vulnerabilities[]/remediations[]/entitlements[]', + '/vulnerabilities[]/remediations[]/restart_required/details', + '/vulnerabilities[]/threats[]/details', + '/vulnerabilities[]/title', + ]) { + await checkPath( + [], + path.split('/').slice(1), + doc, + async (instancePath, text) => { + if (typeof text !== 'string') return + const result = await checkString(text, lang) + if (result.length) { + ctx.infos.push({ + instancePath, + message: result.map((r) => r.message).join(' '), + }) + } + } + ) + } + + return ctx +} + +/** + * Checks the value behind `path` using the given `onCheck` function. This is a + * recursive helper function to loop through the list of paths in the spec. + * + * @param {string[]} reminder + * @param {string[]} path + * @param {unknown} value + * @param {(instancePath: string, value: string) => Promise} onCheck + */ +async function checkPath(reminder, path, value, onCheck) { + if (value == null) return + const currentSegment = path.at(0) + + if (!currentSegment) { + // We've reached the end. Now the `onCheck` function can be called to check + // the actual value. + if (typeof value === 'string') { + await onCheck('/' + reminder.join('/'), value) + } + } else if (currentSegment.endsWith('[]')) { + // The value is supposed to be an array for which every element needs to be + // checked ... + const arrayName = currentSegment.split('[')[0] + const array = Reflect.get(value, arrayName) + + if (Array.isArray(array)) { + // ... But only if it's really an array. + for (const [elementIndex, element] of array.entries() ?? []) { + await checkPath( + [...reminder, arrayName, String(elementIndex)], + [...path.slice(1)], + element, + onCheck + ) + } + } + } else { + // Otherwise it's something object-ish which we traverse recursively. + await checkPath( + [...reminder, currentSegment], + path.slice(1), + Reflect.get(value, currentSegment), + onCheck + ) + } +} + +/** + * Check the given string using the languagetool server. + * + * @param {string} str + * @param {string} lng + * @returns + */ +async function checkString(str, lng) { + /** + * @typedef {object} Match + * @property {string} message + */ + + /** + * @typedef {object} Response + * @property {Match[]} matches + */ + + const res = await fetch(new URL('/v2/check', context.languageToolUrl), { + method: 'POST', + body: new URLSearchParams([ + ['language', lng], + ['text', str], + ]), + }) + if (!res.ok) throw new Error('request to languagetool failed') + + const json = /** @type {Response} */ (await res.json()) + return json.matches +} diff --git a/scripts/test.js b/scripts/test.js index c2b42993..d84f4b58 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -3,14 +3,18 @@ import { spawn } from 'child_process' import { fileURLToPath } from 'url' -spawn('mocha', ['tests', 'tests/csaf_2_1', ...process.argv.slice(2)], { - stdio: 'inherit', - shell: true, - env: { - ...process.env, - DICPATH: fileURLToPath(new URL('../tests/dicts', import.meta.url)), - WORDLIST: fileURLToPath( - new URL('../tests/dicts/csaf_words.txt', import.meta.url) - ), - }, -}) +spawn( + 'mocha', + ['-t', '10000', 'tests', 'tests/csaf_2_1', ...process.argv.slice(2)], + { + stdio: 'inherit', + shell: true, + env: { + ...process.env, + DICPATH: fileURLToPath(new URL('../tests/dicts', import.meta.url)), + WORDLIST: fileURLToPath( + new URL('../tests/dicts/csaf_words.txt', import.meta.url) + ), + }, + } +) diff --git a/tests/csaf_2_1/informativeTest_6_3_16.js b/tests/csaf_2_1/informativeTest_6_3_16.js new file mode 100644 index 00000000..b1f6539e --- /dev/null +++ b/tests/csaf_2_1/informativeTest_6_3_16.js @@ -0,0 +1,21 @@ +import assert from 'node:assert' +import { informativeTest_6_3_16 } from '../../csaf_2_1/informativeTests.js' +import { expect } from 'chai' + +describe('informativeTest_6_3_16', function () { + it('only runs on relevant documents', async function () { + assert.equal( + (await informativeTest_6_3_16({ document: 'mydoc' })).infos.length, + 0 + ) + }) + + it('fails if the language is not known', async function () { + const result = await informativeTest_6_3_16({ + document: { + lang: 'zz', + }, + }) + expect(result.infos.length).to.eq(1) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 32fdcaf9..dab232ff 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -59,7 +59,6 @@ const excluded = [ '6.3.13', '6.3.14', '6.3.15', - '6.3.16', '6.3.17', ]