From fc5a8300c951c789921f23fd2b8710238e726d92 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Tue, 21 Jul 2026 13:41:25 -0500 Subject: [PATCH 1/4] Add CI workflow and node --test suite Adds a GitHub Actions CI workflow (typecheck, build, test) on the supported Node 24 range, and converts the existing dist smoke script into a proper `node --test` suite covering schema validation, all six business rules, and the warning registry. - .github/workflows/ci.yml: run on push/PR to dev and main - test/validate.test.mjs: record + published/rejected CNA container cases - test/warnings.test.mjs: WarningRegistry object, positional, and error paths - package.json: `test` now runs typecheck + build + node --test; add `test:unit` - README: document `npm test` and `npm run test:unit` Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 41 +++++++ README.md | 12 ++ package.json | 3 +- test/validate.test.mjs | 237 +++++++++++++++++++++++++++++++++++++++ test/warnings.test.mjs | 70 ++++++++++++ 5 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 test/validate.test.mjs create mode 100644 test/warnings.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..456de99 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: ['24.13.0', '24'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Test + run: npm run test:unit diff --git a/README.md b/README.md index 8ebe504..7dd63e0 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,18 @@ Typecheck: npm run typecheck ``` +Run the test suite (typecheck, build, then `node --test`): + +```sh +npm test +``` + +Run only the tests against an existing build in `dist/`: + +```sh +npm run test:unit +``` + Run local smoke tests: ```sh diff --git a/package.json b/package.json index caa6ba8..883546d 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "dev": "tsx watch main.js", "typecheck": "tsc --noEmit", "prepare": "npm run build", - "test": "npm run typecheck", + "test": "npm run typecheck && npm run build && node --test", + "test:unit": "node --test", "test:local": "npm run build && node scripts/test-local.mjs", "warning:stub": "npm run build && node scripts/create-stub-warning.mjs" }, diff --git a/test/validate.test.mjs b/test/validate.test.mjs new file mode 100644 index 0000000..1448dac --- /dev/null +++ b/test/validate.test.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { Validate, readJsonFile } from '../dist/index.js'; + +const SCHEMA_ERROR = 'FAILED_JSON_SCHEMA_VALIDATION'; +const SCHEMA_MESSAGE = 'CVE JSON schema is invalid'; +const CVE_RULE_ERROR = 'FAILED_RULES_VALIDATION'; +const CVE_RULE_MESSAGE = 'Invalid property/ies'; + +const validator = new Validate(); + +const validRecord = await readJsonFile('scripts/test-data/cve-record-valid.json'); +const validPublishedCnaContainer = await readJsonFile( + 'scripts/test-data/cna-container/published-cna-container-valid.json', +); +const validRejectedCnaContainer = await readJsonFile( + 'scripts/test-data/cna-container/rejected-cna-container-valid.json', +); + +describe('validateCveRecord — valid input', () => { + it('accepts a valid CVE Record', async () => { + const result = await validator.validateCveRecord(validRecord); + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.details, null); + }); +}); + +describe('validateCveRecord — schema failures', () => { + it('rejects a record missing the cna container', async () => { + const record = await readJsonFile( + 'scripts/test-data/cve-record-missing-cna-container.json', + ); + const result = await validator.validateCveRecord(record); + + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + assert.equal(result.message, SCHEMA_MESSAGE); + assert.ok(result.details); + assert.ok( + result.details.errors.some( + (error) => ( + typeof error !== 'string' + && error.instancePath === '/containers' + && error.keyword === 'required' + ), + ), + ); + }); +}); + +describe('validateCveRecord — business rules', () => { + it('rejects a future datePublic', async () => { + const record = await readJsonFile( + 'scripts/test-data/cve-record-invalid-datePublic-future-date.json', + ); + const result = await validator.validateCveRecord(record); + + assert.equal(result.valid, false); + assert.equal(result.error, CVE_RULE_ERROR); + assert.equal(result.message, CVE_RULE_MESSAGE); + assert.deepEqual(result.details, [ + { + msg: 'datePublic cannot be a future date', + param: 'containers.cna.datePublic', + }, + ]); + }); + + it('rejects an invalid cna timeline datetime', async () => { + const record = structuredClone(validRecord); + record.containers.cna.timeline = [ + { time: '2026-05-01T00:00:00+99:99', lang: 'en-US', value: 'Invalid timezone offset' }, + ]; + const result = await validator.validateCveRecord(record); + + assert.equal(result.valid, false); + assert.equal(result.error, CVE_RULE_ERROR); + assert.deepEqual(result.details, [ + { + msg: 'Invalid date string: 2026-05-01T00:00:00+99:99', + param: 'containers.cna.timeline', + }, + ]); + }); + + it('rejects an invalid adp timeline datetime', async () => { + const record = structuredClone(validRecord); + record.containers.adp[0].timeline = [ + { time: '2026-05-01T00:00:00+99:99', lang: 'en-US', value: 'Invalid ADP timezone offset' }, + ]; + const result = await validator.validateCveRecord(record); + + assert.equal(result.valid, false); + assert.equal(result.error, CVE_RULE_ERROR); + assert.deepEqual(result.details, [ + { + msg: 'Invalid date string: 2026-05-01T00:00:00+99:99', + param: 'containers.adp[0].timeline', + }, + ]); + }); + + it('rejects duplicate English language descriptions', async () => { + const record = structuredClone(validRecord); + record.containers.cna.descriptions = [ + ...validRecord.containers.cna.descriptions, + { lang: 'en-US', value: 'Second English description with the same language code.' }, + ]; + const result = await validator.validateCveRecord(record); + + assert.equal(result.valid, false); + assert.equal(result.error, CVE_RULE_ERROR); + assert.deepEqual(result.details, [ + { + msg: "Cannot have more than one English language entry in 'containers.cna.descriptions'", + param: 'containers.cna.descriptions', + }, + ]); + }); + + it('rejects a whitespace-only description', async () => { + const record = structuredClone(validRecord); + record.containers.cna.descriptions[0].value = ' '; + const result = await validator.validateCveRecord(record); + + assert.equal(result.valid, false); + assert.equal(result.error, CVE_RULE_ERROR); + assert.deepEqual(result.details, [ + { + msg: "Description in 'containers.cna.descriptions' must contain at least one non-whitespace character", + param: 'containers.cna.descriptions', + }, + ]); + }); + + it('rejects a versioned PURL', async () => { + const record = structuredClone(validRecord); + record.containers.cna.affected[0].packageURL = 'pkg:npm/sharepoint@1.0.0'; + const result = await validator.validateCveRecord(record); + + assert.equal(result.valid, false); + assert.equal(result.error, CVE_RULE_ERROR); + assert.deepEqual(result.details, [ + { + msg: 'The PURL version component is currently not supported by the CVE schema: pkg:npm/sharepoint@1.0.0', + param: 'containers.cna.affected', + }, + ]); + }); +}); + +describe('validatePublishedCveCnaContainer', () => { + it('accepts a valid published container', async () => { + const result = await validator.validatePublishedCveCnaContainer(validPublishedCnaContainer); + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.details, null); + }); + + it('rejects a future datePublic', async () => { + const container = structuredClone(validPublishedCnaContainer); + container.cnaContainer.datePublic = '2028-05-21T14:00:00.000Z'; + const result = await validator.validatePublishedCveCnaContainer(container); + + assert.equal(result.valid, false); + assert.equal(result.error, CVE_RULE_ERROR); + assert.deepEqual(result.details, [ + { msg: 'datePublic cannot be a future date', param: 'cnaContainer.datePublic' }, + ]); + }); + + it('rejects a container missing the cnaContainer wrapper', async () => { + const container = await readJsonFile( + 'scripts/test-data/cna-container/published-cna-container-missing-container.json', + ); + const result = await validator.validatePublishedCveCnaContainer(container); + + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + assert.ok(result.details); + assert.ok( + result.details.errors.some( + (error) => ( + typeof error !== 'string' + && error.keyword === 'required' + && error.params.missingProperty === 'cnaContainer' + ), + ), + ); + }); +}); + +describe('validateRejectedCveCnaContainer', () => { + it('accepts a valid rejected container', async () => { + const result = await validator.validateRejectedCveCnaContainer(validRejectedCnaContainer); + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.details, null); + }); + + it('rejects a whitespace-only rejectedReasons entry', async () => { + const container = structuredClone(validRejectedCnaContainer); + container.cnaContainer.rejectedReasons[0].value = ' '; + const result = await validator.validateRejectedCveCnaContainer(container); + + assert.equal(result.valid, false); + assert.equal(result.error, CVE_RULE_ERROR); + assert.deepEqual(result.details, [ + { + msg: "Description in 'cnaContainer.rejectedReasons' must contain at least one non-whitespace character", + param: 'cnaContainer.rejectedReasons', + }, + ]); + }); + + it('rejects a container missing rejectedReasons', async () => { + const container = await readJsonFile( + 'scripts/test-data/cna-container/rejected-cna-container-missing-rejectedReasons.json', + ); + const result = await validator.validateRejectedCveCnaContainer(container); + + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + assert.ok(result.details); + assert.ok( + result.details.errors.some( + (error) => ( + typeof error !== 'string' + && error.keyword === 'required' + && error.params.missingProperty === 'rejectedReasons' + ), + ), + ); + }); +}); diff --git a/test/warnings.test.mjs b/test/warnings.test.mjs new file mode 100644 index 0000000..3a2b20f --- /dev/null +++ b/test/warnings.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, it } from 'node:test'; + +import { Diagnostics, WarningRegistry } from '../dist/index.js'; + +const warning = { + messageId: 'CVE_SCHEMA_DEPRECATION', + notificationMessage: 'This field will be removed in a future schema version.', + notificationDetails: 'Use the replacement field before the warning end date.', + schemaPath: '#/definitions/example/properties/deprecatedField', + dateAdded: '2026-07-01T00:00:00.000Z', + dateUpdated: '2026-07-02T00:00:00.000Z', + dateStart: '2026-08-01T00:00:00.000Z', + dateEnd: '2027-08-01T00:00:00.000Z', + priority: 1, +}; + +describe('WarningRegistry', () => { + let temporaryDirectory; + let registryPath; + + before(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'cve-warning-registry-')); + registryPath = join(temporaryDirectory, 'warnings.json'); + }); + + after(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + it('persists a warning object and exposes it on diagnostics', () => { + const registry = new WarningRegistry(registryPath); + registry.addWarning(warning); + + const diagnostics = Diagnostics.success(new WarningRegistry(registryPath)); + assert.deepEqual(diagnostics.warnings, [warning]); + assert.deepEqual(JSON.parse(readFileSync(registryPath, 'utf8')), [warning]); + }); + + it('accepts positional-argument warnings and serializes cleanly', () => { + const registry = new WarningRegistry(registryPath); + registry.addWarning( + 'CVE_SCHEMA_REMOVAL', + warning.notificationMessage, + warning.notificationDetails, + warning.schemaPath, + warning.dateAdded, + warning.dateUpdated, + warning.dateStart, + warning.dateEnd, + 2, + ); + + const diagnostics = Diagnostics.success(new WarningRegistry(registryPath)); + assert.equal(diagnostics.warnings.length, 2); + assert.deepEqual( + JSON.parse(JSON.stringify(diagnostics)).warnings, + JSON.parse(readFileSync(registryPath, 'utf8')), + ); + }); + + it('throws a TypeError on an invalid warning', () => { + const registry = new WarningRegistry(registryPath); + assert.throws(() => registry.addWarning({ messageId: 'INCOMPLETE' }), TypeError); + }); +}); From a39f9ac0c079fbbc9178eccd4abb395610e6b352 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Tue, 21 Jul 2026 13:59:15 -0500 Subject: [PATCH 2/4] Address critical-review panel findings Applies fixes from the pre-PR review panel before this reaches the public repo: mustFix: - Scope the test runner to the intended suite so scripts/test-local.mjs is no longer swept in by Node's default `test-*` discovery. - Use double quotes in the glob so `npm test` works under cmd.exe on Windows dev machines (single quotes are literal there). - Compute the datePublic future date at runtime instead of hard-coding a calendar date, removing the wall-clock time bomb in the tests. shouldFix: - WarningRegistry tests: per-test isolated registry (beforeEach) and delta assertions, removing inter-test ordering coupling. - CI: drop the redundant explicit build step (npm ci already builds via `prepare`); SHA-pin actions/checkout and actions/setup-node to v4.4.0; set persist-credentials: false on checkout. - Resolve test fixtures relative to import.meta.url (cwd-independent). - Add coverage for the schema-only methods, the custom-schema-path constructor, and defensive/malformed inputs ({}, [], bad state, null). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 13 ++--- package.json | 4 +- test/validate.test.mjs | 106 +++++++++++++++++++++++++++++++++++---- test/warnings.test.mjs | 26 ++++++++-- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 456de99..6774af2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,22 +20,23 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node-version }} cache: npm - - name: Install dependencies + # `npm ci` runs the `prepare` script, which builds the package (tsup), + # so the compiled output in dist/ is available to the test step below. + - name: Install dependencies and build run: npm ci - name: Typecheck run: npm run typecheck - - name: Build - run: npm run build - - name: Test run: npm run test:unit diff --git a/package.json b/package.json index 883546d..7dce31e 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "dev": "tsx watch main.js", "typecheck": "tsc --noEmit", "prepare": "npm run build", - "test": "npm run typecheck && npm run build && node --test", - "test:unit": "node --test", + "test": "npm run typecheck && npm run build && node --test \"test/**/*.test.mjs\"", + "test:unit": "node --test \"test/**/*.test.mjs\"", "test:local": "npm run build && node scripts/test-local.mjs", "warning:stub": "npm run build && node scripts/create-stub-warning.mjs" }, diff --git a/test/validate.test.mjs b/test/validate.test.mjs index 1448dac..022ec60 100644 --- a/test/validate.test.mjs +++ b/test/validate.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; import { describe, it } from 'node:test'; import { Validate, readJsonFile } from '../dist/index.js'; @@ -8,14 +9,22 @@ const SCHEMA_MESSAGE = 'CVE JSON schema is invalid'; const CVE_RULE_ERROR = 'FAILED_RULES_VALIDATION'; const CVE_RULE_MESSAGE = 'Invalid property/ies'; +// Resolve fixtures relative to this file so the suite does not depend on the +// process working directory. +const fixture = (relativePath) => fileURLToPath(new URL(relativePath, import.meta.url)); + +// A future date computed at runtime (~3 years out) so the datePublic +// rejection tests never silently flip to passing on a calendar date. +const futureDate = new Date(Date.now() + 3 * 365 * 24 * 60 * 60 * 1000).toISOString(); + const validator = new Validate(); -const validRecord = await readJsonFile('scripts/test-data/cve-record-valid.json'); +const validRecord = await readJsonFile(fixture('../scripts/test-data/cve-record-valid.json')); const validPublishedCnaContainer = await readJsonFile( - 'scripts/test-data/cna-container/published-cna-container-valid.json', + fixture('../scripts/test-data/cna-container/published-cna-container-valid.json'), ); const validRejectedCnaContainer = await readJsonFile( - 'scripts/test-data/cna-container/rejected-cna-container-valid.json', + fixture('../scripts/test-data/cna-container/rejected-cna-container-valid.json'), ); describe('validateCveRecord — valid input', () => { @@ -30,7 +39,7 @@ describe('validateCveRecord — valid input', () => { describe('validateCveRecord — schema failures', () => { it('rejects a record missing the cna container', async () => { const record = await readJsonFile( - 'scripts/test-data/cve-record-missing-cna-container.json', + fixture('../scripts/test-data/cve-record-missing-cna-container.json'), ); const result = await validator.validateCveRecord(record); @@ -50,11 +59,43 @@ describe('validateCveRecord — schema failures', () => { }); }); +describe('validateCveRecord — defensive / malformed inputs', () => { + it('reports a schema failure for an empty object (no cveMetadata)', async () => { + const result = await validator.validateCveRecord({}); + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + assert.ok(result.details); + assert.ok(result.details.errors.includes('instance.cveMetadata is not defined')); + }); + + it('reports a schema failure for an array input', async () => { + const result = await validator.validateCveRecord([]); + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + assert.ok(result.details); + assert.ok(result.details.errors.includes('instance.cveMetadata is not defined')); + }); + + it('reports a schema failure for an unknown cveMetadata.state', async () => { + const result = await validator.validateCveRecord({ cveMetadata: { state: 'NOT_A_STATE' } }); + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + assert.ok(result.details); + assert.ok(result.details.errors.includes('instance.cveMetadata.state is not one of enum values')); + }); + + // Documents current behavior: a null/undefined record throws rather than + // returning a diagnostic. This pins the behavior so a future hardening of + // the input guard is a deliberate, visible change. + it('throws on a null record (documented current behavior)', async () => { + await assert.rejects(() => validator.validateCveRecord(null)); + }); +}); + describe('validateCveRecord — business rules', () => { it('rejects a future datePublic', async () => { - const record = await readJsonFile( - 'scripts/test-data/cve-record-invalid-datePublic-future-date.json', - ); + const record = structuredClone(validRecord); + record.containers.cna.datePublic = futureDate; const result = await validator.validateCveRecord(record); assert.equal(result.valid, false); @@ -151,6 +192,17 @@ describe('validateCveRecord — business rules', () => { }); }); +describe('Validate — custom schema path', () => { + it('validates a record using an explicitly supplied schema path', async () => { + const customValidator = new Validate( + fixture('../src/schemas/CVE_Record_Format_bundled_5.2.0.json'), + ); + const result = await customValidator.validateCveRecord(validRecord); + assert.equal(result.valid, true); + assert.equal(result.error, null); + }); +}); + describe('validatePublishedCveCnaContainer', () => { it('accepts a valid published container', async () => { const result = await validator.validatePublishedCveCnaContainer(validPublishedCnaContainer); @@ -161,7 +213,7 @@ describe('validatePublishedCveCnaContainer', () => { it('rejects a future datePublic', async () => { const container = structuredClone(validPublishedCnaContainer); - container.cnaContainer.datePublic = '2028-05-21T14:00:00.000Z'; + container.cnaContainer.datePublic = futureDate; const result = await validator.validatePublishedCveCnaContainer(container); assert.equal(result.valid, false); @@ -173,7 +225,7 @@ describe('validatePublishedCveCnaContainer', () => { it('rejects a container missing the cnaContainer wrapper', async () => { const container = await readJsonFile( - 'scripts/test-data/cna-container/published-cna-container-missing-container.json', + fixture('../scripts/test-data/cna-container/published-cna-container-missing-container.json'), ); const result = await validator.validatePublishedCveCnaContainer(container); @@ -192,6 +244,23 @@ describe('validatePublishedCveCnaContainer', () => { }); }); +describe('validatePublishedCveCnaContainerSchema (schema-only)', () => { + it('accepts a valid published container', async () => { + const result = await validator.validatePublishedCveCnaContainerSchema(validPublishedCnaContainer); + assert.equal(result.valid, true); + assert.equal(result.error, null); + }); + + it('rejects a container missing the cnaContainer wrapper', async () => { + const container = await readJsonFile( + fixture('../scripts/test-data/cna-container/published-cna-container-missing-container.json'), + ); + const result = await validator.validatePublishedCveCnaContainerSchema(container); + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + }); +}); + describe('validateRejectedCveCnaContainer', () => { it('accepts a valid rejected container', async () => { const result = await validator.validateRejectedCveCnaContainer(validRejectedCnaContainer); @@ -217,7 +286,7 @@ describe('validateRejectedCveCnaContainer', () => { it('rejects a container missing rejectedReasons', async () => { const container = await readJsonFile( - 'scripts/test-data/cna-container/rejected-cna-container-missing-rejectedReasons.json', + fixture('../scripts/test-data/cna-container/rejected-cna-container-missing-rejectedReasons.json'), ); const result = await validator.validateRejectedCveCnaContainer(container); @@ -235,3 +304,20 @@ describe('validateRejectedCveCnaContainer', () => { ); }); }); + +describe('validateRejectedCveCnaContainerSchema (schema-only)', () => { + it('accepts a valid rejected container', async () => { + const result = await validator.validateRejectedCveCnaContainerSchema(validRejectedCnaContainer); + assert.equal(result.valid, true); + assert.equal(result.error, null); + }); + + it('rejects a container missing rejectedReasons', async () => { + const container = await readJsonFile( + fixture('../scripts/test-data/cna-container/rejected-cna-container-missing-rejectedReasons.json'), + ); + const result = await validator.validateRejectedCveCnaContainerSchema(container); + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + }); +}); diff --git a/test/warnings.test.mjs b/test/warnings.test.mjs index 3a2b20f..774092e 100644 --- a/test/warnings.test.mjs +++ b/test/warnings.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { after, before, describe, it } from 'node:test'; +import { afterEach, beforeEach, describe, it } from 'node:test'; import { Diagnostics, WarningRegistry } from '../dist/index.js'; @@ -20,15 +20,17 @@ const warning = { }; describe('WarningRegistry', () => { + // Each test gets its own isolated registry file so there is no cross-test + // ordering dependency; every test asserts only on the state it creates. let temporaryDirectory; let registryPath; - before(async () => { + beforeEach(async () => { temporaryDirectory = await mkdtemp(join(tmpdir(), 'cve-warning-registry-')); registryPath = join(temporaryDirectory, 'warnings.json'); }); - after(async () => { + afterEach(async () => { await rm(temporaryDirectory, { recursive: true, force: true }); }); @@ -41,7 +43,7 @@ describe('WarningRegistry', () => { assert.deepEqual(JSON.parse(readFileSync(registryPath, 'utf8')), [warning]); }); - it('accepts positional-argument warnings and serializes cleanly', () => { + it('accepts a positional-argument warning and serializes cleanly', () => { const registry = new WarningRegistry(registryPath); registry.addWarning( 'CVE_SCHEMA_REMOVAL', @@ -56,13 +58,27 @@ describe('WarningRegistry', () => { ); const diagnostics = Diagnostics.success(new WarningRegistry(registryPath)); - assert.equal(diagnostics.warnings.length, 2); + assert.equal(diagnostics.warnings.length, 1); + assert.equal(diagnostics.warnings[0].messageId, 'CVE_SCHEMA_REMOVAL'); + assert.equal(diagnostics.warnings[0].priority, 2); assert.deepEqual( JSON.parse(JSON.stringify(diagnostics)).warnings, JSON.parse(readFileSync(registryPath, 'utf8')), ); }); + it('appends across registry instances backed by the same file', () => { + new WarningRegistry(registryPath).addWarning(warning); + new WarningRegistry(registryPath).addWarning({ ...warning, messageId: 'CVE_SCHEMA_REMOVAL', priority: 2 }); + + const persisted = JSON.parse(readFileSync(registryPath, 'utf8')); + assert.equal(persisted.length, 2); + assert.deepEqual( + persisted.map((entry) => entry.messageId), + ['CVE_SCHEMA_DEPRECATION', 'CVE_SCHEMA_REMOVAL'], + ); + }); + it('throws a TypeError on an invalid warning', () => { const registry = new WarningRegistry(registryPath); assert.throws(() => registry.addWarning({ messageId: 'INCOMPLETE' }), TypeError); From e6f25df82c7d34756c74ffffc2206e89f51fe03f Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Tue, 21 Jul 2026 14:56:25 -0500 Subject: [PATCH 3/4] Address round-2 review findings - Make the custom-schema-path test non-vacuous: validate the same record against a divergent (CNA-container) schema so a fallback-to-default regression is observable (accepted under record schema, rejected under CNA schema). - Consolidate the {}/[] defensive cases into one test that documents they share the missing-cveMetadata pre-check, instead of implying array-specific handling. - CI hardening: add a guard step that fails when no test files match (node --test exits 0 on a zero-match glob); add job timeout-minutes: 15; add a concurrency group with cancel-in-progress; note the intentional split between CI's granular steps and the aggregate `npm test`. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 21 ++++++++++++++++++ test/validate.test.mjs | 47 ++++++++++++++++++++++++---------------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6774af2..3273032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,15 @@ on: permissions: contents: read +# Cancel superseded runs for the same ref so rapid pushes/PR updates don't pile up. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-test: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false @@ -35,8 +41,23 @@ jobs: - name: Install dependencies and build run: npm ci + # `node --test` exits 0 when its glob matches no files, so a renamed or + # moved test directory would otherwise produce a silent green run. Fail + # loudly if no test files are present. + - name: Verify tests are discoverable + run: | + count=$(find test -name '*.test.mjs' | wc -l | tr -d ' ') + echo "Discovered $count test file(s)" + if [ "$count" -eq 0 ]; then + echo "::error::No test files matched test/**/*.test.mjs" >&2 + exit 1 + fi + - name: Typecheck run: npm run typecheck + # CI runs typecheck and the tests as separate steps (rather than the + # aggregate `npm test`) for clearer per-step attribution and to avoid a + # second build — `npm test` remains a developer convenience. - name: Test run: npm run test:unit diff --git a/test/validate.test.mjs b/test/validate.test.mjs index 022ec60..6099b43 100644 --- a/test/validate.test.mjs +++ b/test/validate.test.mjs @@ -60,20 +60,17 @@ describe('validateCveRecord — schema failures', () => { }); describe('validateCveRecord — defensive / malformed inputs', () => { - it('reports a schema failure for an empty object (no cveMetadata)', async () => { - const result = await validator.validateCveRecord({}); - assert.equal(result.valid, false); - assert.equal(result.error, SCHEMA_ERROR); - assert.ok(result.details); - assert.ok(result.details.errors.includes('instance.cveMetadata is not defined')); - }); - - it('reports a schema failure for an array input', async () => { - const result = await validator.validateCveRecord([]); - assert.equal(result.valid, false); - assert.equal(result.error, SCHEMA_ERROR); - assert.ok(result.details); - assert.ok(result.details.errors.includes('instance.cveMetadata is not defined')); + // An empty object and an array both lack cveMetadata, so both are rejected by + // the same pre-check before AJV runs. Covered together to document that shared + // graceful path rather than implying array-specific schema handling. + it('reports a schema failure for inputs without cveMetadata ({} and [])', async () => { + for (const input of [{}, []]) { + const result = await validator.validateCveRecord(input); + assert.equal(result.valid, false); + assert.equal(result.error, SCHEMA_ERROR); + assert.ok(result.details); + assert.ok(result.details.errors.includes('instance.cveMetadata is not defined')); + } }); it('reports a schema failure for an unknown cveMetadata.state', async () => { @@ -193,13 +190,25 @@ describe('validateCveRecord — business rules', () => { }); describe('Validate — custom schema path', () => { - it('validates a record using an explicitly supplied schema path', async () => { - const customValidator = new Validate( + it('uses the explicitly supplied schema instead of the default', async () => { + // Same record, two different schemas. Under the default CVE Record schema the + // record is accepted; under a CNA-container schema (a deliberately different + // shape) the same record is rejected. If the custom path were ignored and the + // default used, both would pass — so the divergent outcome proves the supplied + // schema is actually applied, not silently falling back to the default. + const recordSchemaValidator = new Validate( fixture('../src/schemas/CVE_Record_Format_bundled_5.2.0.json'), ); - const result = await customValidator.validateCveRecord(validRecord); - assert.equal(result.valid, true); - assert.equal(result.error, null); + const acceptedUnderRecordSchema = await recordSchemaValidator.validateCveRecord(validRecord); + assert.equal(acceptedUnderRecordSchema.valid, true); + assert.equal(acceptedUnderRecordSchema.error, null); + + const cnaContainerSchemaValidator = new Validate( + fixture('../src/schemas/5.2.0_published_cna_container.json'), + ); + const rejectedUnderCnaSchema = await cnaContainerSchemaValidator.validateCveRecord(validRecord); + assert.equal(rejectedUnderCnaSchema.valid, false); + assert.equal(rejectedUnderCnaSchema.error, SCHEMA_ERROR); }); }); From c01e6d3a5f579fb0ae4a2c847c0ff90113c700df Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Tue, 21 Jul 2026 15:08:10 -0500 Subject: [PATCH 4/4] Address round-3 non-blocking CI nits - Scope concurrency cancel-in-progress to non-protected refs so per-commit CI on dev/main always completes (avoids canceling an earlier commit's run). - Guard the test-discovery find with 2>/dev/null and `|| true` so a missing test/ directory reaches the explicit check and emits the intended ::error:: annotation instead of aborting at the assignment. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3273032..9916175 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,10 +9,12 @@ on: permissions: contents: read -# Cancel superseded runs for the same ref so rapid pushes/PR updates don't pile up. +# Cancel superseded runs for the same ref so rapid PR updates don't pile up, +# but keep per-commit CI on the protected branches (dev, main) so each commit +# there still gets a completed run. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} jobs: build-and-test: @@ -46,7 +48,9 @@ jobs: # loudly if no test files are present. - name: Verify tests are discoverable run: | - count=$(find test -name '*.test.mjs' | wc -l | tr -d ' ') + # `|| true` so a missing test/ directory does not abort at the + # assignment (set -e + pipefail); the explicit check below reports it. + count=$(find test -name '*.test.mjs' 2>/dev/null | wc -l | tr -d ' ' || true) echo "Discovered $count test file(s)" if [ "$count" -eq 0 ]; then echo "::error::No test files matched test/**/*.test.mjs" >&2