diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9916175 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +permissions: + contents: read + +# 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: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} + +jobs: + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + node-version: ['24.13.0', '24'] + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + # `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 + + # `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: | + # `|| 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 + 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/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..7dce31e 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/**/*.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 new file mode 100644 index 0000000..6099b43 --- /dev/null +++ b/test/validate.test.mjs @@ -0,0 +1,332 @@ +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +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'; + +// 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(fixture('../scripts/test-data/cve-record-valid.json')); +const validPublishedCnaContainer = await readJsonFile( + fixture('../scripts/test-data/cna-container/published-cna-container-valid.json'), +); +const validRejectedCnaContainer = await readJsonFile( + fixture('../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( + fixture('../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 — defensive / malformed inputs', () => { + // 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 () => { + 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 = structuredClone(validRecord); + record.containers.cna.datePublic = futureDate; + 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('Validate — custom schema path', () => { + 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 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); + }); +}); + +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 = futureDate; + 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( + fixture('../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('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); + 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( + fixture('../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' + ), + ), + ); + }); +}); + +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 new file mode 100644 index 0000000..774092e --- /dev/null +++ b/test/warnings.test.mjs @@ -0,0 +1,86 @@ +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 { afterEach, beforeEach, 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', () => { + // 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; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'cve-warning-registry-')); + registryPath = join(temporaryDirectory, 'warnings.json'); + }); + + afterEach(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 a positional-argument warning 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, 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); + }); +});