diff --git a/README.md b/README.md index 3f4d5f1..eb51bd5 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ curl -X POST "http://localhost:3000/search" \ ### Virtual Match String Version Ranges -Use `versionStart`, `versionStartType`, `versionEnd`, and `versionEndType` with `virtualMatchString` to search CPE Match String ranges. Each provided version bound must include its matching type, and the `virtualMatchString` must be a partial CPE 2.3 match string without a version component. When both bounds are provided, `versionStart` must be less than `versionEnd`; equal values are valid only when both bounds are `including`. +Use `versionStart`, `versionStartType`, `versionEnd`, and `versionEndType` with `virtualMatchString` to search CPE Match String ranges. Each provided version bound must include its matching type, and the `virtualMatchString` must be a partial CPE 2.3 match string without a version component. Negative numeric values and the standalone CPE logical `NA` value (`-`) are not accepted as ordered range boundaries. When both bounds are provided, `versionStart` must be less than `versionEnd`; equal values are valid only when both bounds are `including`. ```bash curl -X POST "http://localhost:3000/search" \ @@ -276,6 +276,11 @@ Each `/search` request owns a lifecycle cancellation signal. When its overall de ### Semantic Post-Filtering +Two separate 10,000-record controls apply to `/search`: + +- Every requested page must fall within the first 10,000 results. Direct OpenSearch filters still report an exact total that can exceed 10,000, but pages beyond that window are rejected. +- Filters that require semantic post-filtering inspect at most 10,000 OpenSearch candidates. Their reported total and pagination describe only matches found within that candidate set. + CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` searches, `cpeName`, and `virtualMatchString` searches use semantic post-filtering where OpenSearch cannot guarantee the required same-entry or version-comparison behavior. These searches retrieve up to 10,000 lightweight candidates, apply semantic checks in Node.js, paginate the matching CVE IDs, and fetch the requested page as complete CVE records. Every slash-delimited token supplied to one CVSS vector filter must match a complete token in the same eligible vector; a base token such as `AV:N` does not match a modified token such as `MAV:N`. The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 73a4c7b..2a2cffa 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -21,7 +21,7 @@ "CVE Record" ], "summary": "Retrieves all matching CVE records after applying the JSON request body as filters", - "description": "
Retrieves all matching CVE records after applying the JSON request body as filters
", + "description": "Retrieves all matching CVE records after applying the JSON request body as filters
Direct searches report the exact number of matching records, including totals greater than 10,000, but requested pages must remain within the first 10,000 results.
CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. These searches evaluate at most 10,000 OpenSearch candidates, so their reported totals and pages include only matches found within that candidate set.
", "operationId": "search", "responses": { "200": { @@ -436,7 +436,8 @@ "properties": { "value": { "type": "integer", - "minimum": 0 + "minimum": 0, + "description": "For direct searches, the exact number of matching records, which may be greater than 10,000. For semantic post-filtered searches, the number of matches found within the bounded 10,000-candidate set." } } }, @@ -477,7 +478,8 @@ }, "totalPages": { "type": "integer", - "minimum": 0 + "minimum": 0, + "description": "Pages derived from the reported total and requested page size. Semantic post-filtered searches derive this value from matches found within the bounded 10,000-candidate set." }, "status": { "type": "string", @@ -550,7 +552,7 @@ }, "SearchRequest": { "type": "object", - "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end.", + "description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end. Direct searches report exact totals even when they exceed 10,000, although requested pages must remain inside that window. CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most 10,000 OpenSearch candidates, so reported totals and pages include only matches found within that candidate set.", "additionalProperties": false, "properties": { "scope": { @@ -744,20 +746,20 @@ "minimum": 1, "maximum": 500, "default": 20, - "description": "Specifies the maximum number of CVE records returned in one response. The requested page must remain within the first 10000 results." + "description": "Specifies the maximum number of CVE records returned in one response. For every search, the requested page must remain within the first 10,000 results. This pagination window does not cap the exact total reported by a direct search." }, "pageNumber": { "type": "integer", "format": "int32", "minimum": 1, "default": 1, - "description": "Specifies the page to return based on resultsPerPage. The requested page must remain within the first 10000 results." + "description": "Specifies the page to return based on resultsPerPage. For every search, the requested page must remain within the first 10,000 results. This pagination window does not cap the exact total reported by a direct search." }, "versionStart": { "type": "string", "maxLength": 256, "example": "2.2", - "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionStartType": { "type": "string", @@ -771,7 +773,7 @@ "type": "string", "maxLength": 256, "example": "2.6", - "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." + "description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component." }, "versionEndType": { "type": "string", @@ -861,4 +863,4 @@ } } } -} +} \ No newline at end of file diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index d724de3..88428cf 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -59,6 +59,9 @@ describe('OpenAPI contract', () => { expect(mediaType.schema).toStrictEqual({ $ref: '#/components/schemas/SearchRequest' }); expect(schema.additionalProperties).toBe(false); expect(schema.description).toContain('the start must be before or equal to the end'); + expect(schema.description).toContain('Direct searches report exact totals'); + expect(schema.description).toContain('semantic post-filtering'); + expect(schema.description).toContain('10,000 OpenSearch candidates'); expect(Object.keys(schema.properties)).toStrictEqual(constants.SEARCH_QUERY_PARAMS); Object.values(schema.properties).forEach((property) => { expect(property.description).toEqual(expect.any(String)); @@ -109,14 +112,18 @@ describe('OpenAPI contract', () => { expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES); expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT); + expect(schema.properties.resultsPerPage.description).toContain('does not cap the exact total'); + expect(schema.properties.pageNumber.description).toContain('first 10,000 results'); expect(schema.properties.cveId).toBeUndefined(); expect(schema.properties.sourceIdentifier).toBeUndefined(); expect(schema.properties.versionStart.description).toContain('versionStartType'); expect(schema.properties.versionStart.description).toContain('less than versionEnd'); expect(schema.properties.versionStart.description).toContain('ranges ignore leading zeros'); expect(schema.properties.versionStart.description).toContain('Maven-style ordering'); + expect(schema.properties.versionStart.description).toContain('Negative numeric values'); expect(schema.properties.versionEnd.description).toContain('versionEndType'); expect(schema.properties.versionEnd.description).toContain('exact CPE Match Criteria preserve leading zeros'); + expect(schema.properties.versionEnd.description).toContain('standalone CPE logical NA value'); expect(schema.properties.versionEnd.description).toContain( 'prerelease qualifiers before an unqualified release' ); @@ -197,7 +204,12 @@ describe('OpenAPI contract', () => { ]); expect(searchResponse.properties.pageSize.description).toContain('requested for this page'); expect(searchResponse.properties.resultsReturned.description).toContain('actually returned'); + expect(searchResponse.properties.totalPages.description).toContain('10,000-candidate set'); expect(searchResponse.properties.data.properties.hits.$ref).toBe('#/components/schemas/SearchHits'); + expect(schemas.SearchHits.properties.total.properties.value.description).toContain( + 'exact number of matching records' + ); + expect(schemas.SearchHits.properties.total.properties.value.description).toContain('10,000-candidate set'); expect(schemas.SearchHits.properties.hits.items.$ref).toBe('#/components/schemas/SearchHit'); expect(schemas.ApiError.required).toStrictEqual(['error', 'message']); expect(schemas.ApiErrorResponse.oneOf).toHaveLength(2); diff --git a/controllers/middleware.js b/controllers/middleware.js index 07df429..93aa66b 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -92,6 +92,11 @@ function isSingleNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } +function isUnsupportedVersionRangeBoundary(value) { + const normalized = String(value ?? '').trim(); + return normalized === '-' || /^-\d/.test(normalized); +} + function isBoolean(value) { return value === true || value === false; } @@ -252,6 +257,10 @@ function validateParams(req, res, next) { if (hasParam(params, param) && !isSingleNonEmptyString(params[param])) { return res.status(400).json(error.versionRangeValueError(param)); } + + if (hasParam(params, param) && isUnsupportedVersionRangeBoundary(params[param])) { + return res.status(400).json(error.versionRangeBoundaryError(param)); + } } for (const param of versionRangeTypeParams) { diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index a1d5841..2422403 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -308,6 +308,23 @@ describe('middleware validation function', () => { expectValidationError('VERSION_RANGE_VALUE', `The ${param} parameter must be a non-empty string.`); }); + test.each([ + ['versionStart', '-1'], + ['versionEnd', '-1'], + ['versionStart', '-'] + ])('ValidateParams rejects unsupported ordered version boundary %s=%s.', (param, value) => { + validate({ + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + [param]: value, + [`${param}Type`]: 'including' + }); + + expectValidationError( + 'VERSION_RANGE_BOUNDARY', + `The ${param} parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.` + ); + }); + test.each([ ['versionStartType', 'inclusive'], ['versionEndType', ['excluding']] diff --git a/routes/index.js b/routes/index.js index 7af0175..083db99 100644 --- a/routes/index.js +++ b/routes/index.js @@ -196,6 +196,9 @@ router.post( #swagger.description = "Retrieves all matching CVE records after applying the JSON request body as filters
+Direct searches report the exact number of matching records, including totals greater than 10,000, but requested pages must remain within the first 10,000 results.
+CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. These searches evaluate at most 10,000 OpenSearch candidates, so their reported totals and pages include only matches found within that candidate set.
#swagger.requestBody = { required: false, content: { diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index deb1700..c97c124 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -241,6 +241,26 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test('POST /search rejects a negative numeric version bound before invoking OpenSearch', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', + versionEnd: '-1', + versionEndType: 'including' + } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'VERSION_RANGE_BOUNDARY', + message: + 'The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.' + }); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test('POST /webSearch normalizes body fields and formats BasicSearchManager responses', async () => { mockBasicSearch.mockResolvedValue(basicResponse({ total: 1, hits: [{ _id: 'CVE-2024-0002' }] })); diff --git a/routes/swagger.js b/routes/swagger.js index 0996449..9092d25 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -8,6 +8,8 @@ const ssvc = require('../utils/ssvc'); const projectRoot = path.join(__dirname, '..'); const outputFile = path.join(projectRoot, 'api-docs', 'openapi.json'); const routes = [path.join(__dirname, 'index.js')]; +const maxSearchWindowLabel = constants.MAX_SEARCH_WINDOW.toLocaleString('en-US'); +const semanticPostFilterDescription = `CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most ${maxSearchWindowLabel} OpenSearch candidates, so reported totals and pages include only matches found within that candidate set.`; function searchRequestProperty(description, schema) { return { @@ -192,7 +194,7 @@ const searchRequestProperties = { } ), resultsPerPage: searchRequestProperty( - `Specifies the maximum number of CVE records returned in one response. The requested page must remain within the first ${constants.MAX_SEARCH_WINDOW} results.`, + `Specifies the maximum number of CVE records returned in one response. For every search, the requested page must remain within the first ${maxSearchWindowLabel} results. This pagination window does not cap the exact total reported by a direct search.`, { type: 'integer', format: 'int32', @@ -202,7 +204,7 @@ const searchRequestProperties = { } ), pageNumber: searchRequestProperty( - `Specifies the page to return based on resultsPerPage. The requested page must remain within the first ${constants.MAX_SEARCH_WINDOW} results.`, + `Specifies the page to return based on resultsPerPage. For every search, the requested page must remain within the first ${maxSearchWindowLabel} results. This pagination window does not cap the exact total reported by a direct search.`, { type: 'integer', format: 'int32', @@ -211,7 +213,7 @@ const searchRequestProperties = { } ), versionStart: searchRequestProperty( - 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionStart, @@ -226,7 +228,7 @@ const searchRequestProperties = { } ), versionEnd: searchRequestProperty( - 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', + 'Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component.', { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.versionEnd, @@ -402,7 +404,8 @@ const doc = { properties: { value: { type: 'integer', - minimum: 0 + minimum: 0, + description: `For direct searches, the exact number of matching records, which may be greater than ${maxSearchWindowLabel}. For semantic post-filtered searches, the number of matches found within the bounded ${maxSearchWindowLabel}-candidate set.` } } }, @@ -436,7 +439,8 @@ const doc = { }, totalPages: { type: 'integer', - minimum: 0 + minimum: 0, + description: `Pages derived from the reported total and requested page size. Semantic post-filtered searches derive this value from matches found within the bounded ${maxSearchWindowLabel}-candidate set.` }, status: { type: 'string', @@ -497,8 +501,7 @@ const doc = { }, SearchRequest: { type: 'object', - description: - 'Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end.', + description: `Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end. Direct searches report exact totals even when they exceed ${maxSearchWindowLabel}, although requested pages must remain inside that window. ${semanticPostFilterDescription}`, additionalProperties: false, properties: searchRequestProperties } diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 7624885..048dad6 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -5760,6 +5760,50 @@ }, "response": [] }, + { + "name": "Negative numeric version range boundary", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 400', () => pm.response.to.have.status(400));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + "const payload = pm.response.json();", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"VERSION_RANGE_BOUNDARY\",\"message\":\"The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.\"};", + "pm.test('expected error code is returned', () => {", + " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", + "});", + "pm.test('expected error message is returned', () => {", + " const match = errors.find((entry) => entry.error === expectedError.error);", + " pm.expect(match?.message).to.eql(expectedError.message);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json", "type": "text" }], + "url": { + "raw": "{{baseUrl}}/search", + "host": ["{{baseUrl}}"], + "path": ["search"], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"virtualMatchString\": \"cpe:2.3:o:linux:linux_kernel\",\n \"versionEnd\": \"-1\",\n \"versionEndType\": \"including\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "Invalid ranged virtualMatchString format", "event": [ diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 7fe3bb2..412b5bb 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -1001,6 +1001,11 @@ const cpeAndVersionFailures = folder( { virtualMatchString: 'cpe:2.3:a:vendor:product', versionStart: '1.0', versionStartType: 'before' }, error.versionRangeTypeError('versionStartType') ), + negative( + 'Negative numeric version range boundary', + { virtualMatchString: 'cpe:2.3:o:linux:linux_kernel', versionEnd: '-1', versionEndType: 'including' }, + error.versionRangeBoundaryError('versionEnd') + ), negative( 'Invalid ranged virtualMatchString format', { virtualMatchString: 'not-a-cpe', versionStart: '1.0', versionStartType: 'including' }, diff --git a/utils/cpe.js b/utils/cpe.js index 7301566..b59719a 100644 --- a/utils/cpe.js +++ b/utils/cpe.js @@ -230,6 +230,10 @@ function tokenizeVersion(value) { .match(/\d+|[a-z]+/g); } +function isNegativeNumericVersion(value) { + return /^-\d/.test(String(value ?? '').trim()); +} + function compareNumericTokens(a, b) { const normalizedA = a.replace(/^0+/, '') || '0'; const normalizedB = b.replace(/^0+/, '') || '0'; @@ -270,6 +274,16 @@ function compareVersionTokenToRelease(token) { } function compareCpeVersions(a, b) { + const negativeA = isNegativeNumericVersion(a); + const negativeB = isNegativeNumericVersion(b); + + if (negativeA !== negativeB) return negativeA ? -1 : 1; + + if (negativeA && negativeB) { + const comparison = compareCpeVersions(String(a).trim().slice(1), String(b).trim().slice(1)); + return comparison === 0 ? 0 : -comparison; + } + const tokensA = tokenizeVersion(a); const tokensB = tokenizeVersion(b); diff --git a/utils/cpe.test.js b/utils/cpe.test.js index 916a0d9..0dbce63 100644 --- a/utils/cpe.test.js +++ b/utils/cpe.test.js @@ -169,6 +169,13 @@ describe('CPE helpers', () => { expect(cpe.compareCpeVersions('1.0', '1.0.1')).toBeLessThan(0); }); + test('compareCpeVersions preserves the sign of negative numeric versions', () => { + expect(cpe.compareCpeVersions('-1', '1')).toBeLessThan(0); + expect(cpe.compareCpeVersions('1', '-1')).toBeGreaterThan(0); + expect(cpe.compareCpeVersions('-2', '-1')).toBeLessThan(0); + expect(cpe.compareCpeVersions('-1.0', '-1')).toBe(0); + }); + test('compareCpeVersions orders Maven-style qualifiers around an unqualified release', () => { const orderedVersions = [ '1.0.0-alpha1', diff --git a/utils/errors.js b/utils/errors.js index 292c161..2b1bfa8 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -96,6 +96,13 @@ class errors { return makeError('VERSION_RANGE_VALUE', `The ${param} parameter must be a non-empty string.`); } + versionRangeBoundaryError(param) { + return makeError( + 'VERSION_RANGE_BOUNDARY', + `The ${param} parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.` + ); + } + versionRangeTypeError(param) { return makeError('VERSION_RANGE_TYPE', `The ${param} parameter must be including or excluding.`); } diff --git a/utils/errors.test.js b/utils/errors.test.js index aae9e04..fe3e325 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -91,6 +91,15 @@ describe('error response helpers', () => { message: 'The versionStart parameter must be a non-empty string.' } ], + [ + 'versionRangeBoundaryError', + ['versionEnd'], + { + error: 'VERSION_RANGE_BOUNDARY', + message: + 'The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.' + } + ], [ 'versionRangeTypeError', ['versionEndType'],