diff --git a/.env.example b/.env.example index a6ac790..4afd551 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ OpenSearchDomainEndpoint= SearchOpenSearchRequestTimeoutMs=20000 SearchRequestDeadlineMs=30000 SearchReadinessTimeoutMs=5000 +SearchClientCacheMaxAgeSeconds=60 RequestLogSuccessSampleRate=0.1 RequestLogHealthChecks=false WebSearchEnabled=true diff --git a/README.md b/README.md index d8361ab..ab18413 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - [Virtual Match String Search](#virtual-match-string-search) - [Virtual Match String Version Ranges](#virtual-match-string-version-ranges) - [Raw Query Endpoint](#raw-query-endpoint) +- [Client Result Caching](#client-result-caching) - [Swagger API docs](#swagger-api-docs) - [Testing](#testing) - [Operational Logging](#operational-logging) @@ -38,6 +39,7 @@ Copy `.env.example` to an untracked `.env` and replace or check the following va - `SearchOpenSearchRequestTimeoutMs` - optional timeout for each OpenSearch request, default `20000` - `SearchRequestDeadlineMs` - optional overall `/search` processing deadline, default `30000` - `SearchReadinessTimeoutMs` - optional timeout for the OpenSearch index readiness check, default `5000` +- `SearchClientCacheMaxAgeSeconds` - client result-cache freshness hint in seconds, default `60`; `0` disables reuse with `no-store`. Invalid values also produce `no-store`. - `RequestLogSuccessSampleRate` - optional fraction of successful access logs to retain, default `0.1`; includes health checks when they are enabled for logging - `RequestLogHealthChecks` - optional flag to include successful health checks in access-log sampling, default `false` - `WebSearchEnabled` - optional `/webSearch` availability flag, default `true` @@ -123,17 +125,21 @@ When the configured index is an alias spanning multiple indices, every reported JSON request bodies for both search endpoints are limited to 100 KiB (102,400 bytes, measured after decompression). Larger bodies return HTTP 413 `REQUEST_BODY_TOO_LARGE`. Parser errors return sanitized JSON rather than HTML or stack traces: malformed JSON returns HTTP 400 `MALFORMED_JSON`, other unreadable bodies return HTTP 400 `INVALID_REQUEST`, and unsupported encoding returns HTTP 415 (`SEARCH_CONTENT_TYPE` for `/search`, `REQUEST_ENCODING_UNSUPPORTED` for `/webSearch`). Unexpected server errors return a generic `INTERNAL_SERVER_ERROR` response without exception details, including outside production mode. -`POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. Enum-valued fields are accepted case-insensitively and normalized to their documented canonical values. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. +`POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. Enum-valued fields are accepted case-insensitively and normalized to their documented canonical values. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. Errors use a single `{ "error": "CODE", "message": "Description" }` object; if multiple validation checks fail, the first validation error is returned. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header. Successful `/search` responses use an API-owned response structure rather than exposing search-backend response objects. The top-level fields are `apiVersion`, `status`, `warnings`, `pagination`, and `records`, in that order. `records` contains complete CVE Records directly, without search-backend hit wrappers or infrastructure metadata. `pagination` contains `page`, `pageSize`, `resultsReturned`, `totalResults`, `totalPages`, and `totalIsExact`. Non-fatal conditions are reported in `warnings` before the potentially long `records` array. The API omits the top-level `opensearch_ops` ingestion metadata from each `/search` record, including ingestion timestamps and signatures, while preserving all other record fields and containers. This targeted removal does not validate or repair records against the CVE JSON schema and does not modify the indexed data. +`resultsPerPage` must be an integer from 1 through 500; integer strings are also accepted. Invalid types, fractional values, and nonpositive values return `RESULTS_PER_PAGE_INT`. Positive integers above 500 return `MAX_RESULTS_LIMIT`. + CVE Records do not exist for years before 1999. A syntactically valid pre-1999 value in `cveIds` is therefore a valid search that contributes no matches rather than a request error. The response includes a `CVE_IDS_NOT_RETURNED` warning listing those IDs. When pre-1999 and supported-year IDs are requested together, matching supported-year records are still returned. +Other syntactically valid IDs with no matches contribute zero results and do not produce a missing-ID warning. + Use the optional `scope` field to control which parts of each record participate in matching. Values are case-insensitive: `FULL` (the default) searches base record fields, CNA data, and every ADP provider; `CNA_ONLY` searches base record fields and CNA data; and `ADP_ONLY` searches base record fields and every ADP provider. Scope changes matching only, so successful responses always contain the complete CVE record. CPE applicability filters use CNA data and return HTTP 400 when combined with `ADP_ONLY`. -Date filters use the API's full UTC timestamp format and inclusive boundaries. When both boundaries for publication, modification, SSVC timestamp, or KEV date-added filters are supplied, the start must be before or equal to the end. +Date filters accept `YYYY-MM-DD` or timestamps with seconds and an explicit timezone (`Z` or `+/-HH:MM`), with optional fractional seconds of 1 to 3 digits. For example, `2024-01-01`, `2024-01-01T00:00:00Z`, and `2024-01-01T01:00:00+01:00` are accepted. Values are normalized to UTC. A date-only start means `00:00:00.000Z`; a date-only end means `23:59:59.999Z` on that day. Explicit timestamps retain their exact instant, including end boundaries. Impossible dates, timestamps without a timezone, leap seconds, and precision finer than milliseconds return HTTP 400. When both boundaries for publication, modification, SSVC timestamp, or KEV date-added filters are supplied, the start must be before or equal to the end after normalization. All boundaries are inclusive. CVSS v3.0, v3.1, combined v3, and v4 severity filters accept `NONE`, `LOW`, `MEDIUM`, `HIGH`, and `CRITICAL`, case-insensitively. They match the stored `baseSeverity` label in scope-selected CNA/ADP metrics; the API does not recalculate the label from scores. CVSS v2 instead maps `LOW`, `MEDIUM`, and `HIGH` to base-score ranges and does not accept `NONE` or `CRITICAL`. @@ -157,13 +163,15 @@ Missing data in selected containers does not make an otherwise compatible reques `keywordSearch` searches only description text selected by `scope`. By default, the API escapes caller-supplied query syntax, adds a trailing prefix wildcard to each whitespace-delimited term, and combines terms with `AND` using `simple_query_string` with wildcard analysis enabled. For example, `remote code` searches for `remote* AND code*`. Each prefix term is processed by the selected field's text analyzer. With the standard analyzer, `use-after-free` becomes required `use` and `after` tokens plus a `free*` prefix; those tokens need not be adjacent or in order. This also allows hyphenated CVE and CWE IDs to match when present in description text; use `cveIds` or `cweId` to search their dedicated fields. With `keywordExactMatch: true`, the API instead uses an analyzed phrase query in the selected description fields. Phrase matching follows the index's text analyzer; it is not a case-sensitive, character-for-character substring comparison. -SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE`), `ssvcAutomatable` (`YES` or `NO`), `ssvcTechnicalImpact` (`PARTIAL` or `TOTAL`), `ssvcRole`, `ssvcTimestampStartDate`, and `ssvcTimestampEndDate`. Enum and role comparisons are case-insensitive, timestamps use the API's full UTC timestamp format with inclusive boundaries, and all supplied SSVC conditions must match the same structured SSVC metric entry in a container selected by `scope`. +SSVC metrics can be filtered with `ssvcExploitation` (`NONE`, `POC`, or `ACTIVE`), `ssvcAutomatable` (`YES` or `NO`), `ssvcTechnicalImpact` (`PARTIAL` or `TOTAL`), `ssvcRole`, `ssvcTimestampStartDate`, and `ssvcTimestampEndDate`. Enum and role comparisons are case-insensitive, date boundaries follow the date-input rules above, and all supplied SSVC conditions must match the same structured SSVC metric entry in a container selected by `scope`. -Use `knownExploited` to include or exclude records with a structured KEV metric in containers selected by `scope`. The optional `kevDateAddedStartDate` and `kevDateAddedEndDate` boundaries are inclusive, require `knownExploited: true`, and use the API's full UTC timestamp format. Stored KEV `dateAdded` values are interpreted as midnight UTC. +Use `knownExploited` to include or exclude records with a structured KEV metric in containers selected by `scope`. The optional `kevDateAddedStartDate` and `kevDateAddedEndDate` boundaries follow the date-input rules above and require `knownExploited: true`. Stored KEV `dateAdded` values are interpreted as midnight UTC; a date-only end boundary includes that entire day. Use `referenceURL` for a case-insensitive literal substring search over reference URLs in containers selected by `scope`. The filter accepts one string of at least three characters; user-supplied wildcard syntax is treated literally. -Use the strict boolean `disputed` filter to require (`true`) or exclude (`false`) the exact `disputed` tag in containers selected by `scope`. This does not provide a general-purpose record tag filter. +Use the boolean `disputed` filter to require (`true`) or exclude (`false`) the exact `disputed` tag in containers selected by `scope`. This does not provide a general-purpose record tag filter. + +All boolean filters (`isVulnerable`, `keywordExactMatch`, `noRejected`, `knownExploited`, and `disputed`) accept JSON booleans or the lowercase strings `"true"` and `"false"`. Strings are normalized before filter evaluation; numeric values, null, arrays, and other strings are rejected. String inputs are bounded before query construction: @@ -310,6 +318,14 @@ Both endpoints use the shared OpenSearch client and reject incomplete upstream r The `/webSearch` response retains `searchMetadata`, `resultsTotal`, and `data`. Metadata includes `searchStatus`, the response-format `version`, `notes`, `errors`, and `queryTime_ms` when available. The obsolete `cveCore` and `cveMitreCore` package-version fields have been removed. `/search` keeps its existing API-owned response format, warnings, and filter behavior. +## Client Result Caching + +Successful HTTP 200 responses from both `/search` and `/webSearch`, including zero-result searches, include `Cache-Control: private, max-age=60` by default. `SearchClientCacheMaxAgeSeconds` (or `appConfig.clientCache.maxAgeSeconds`) accepts a nonnegative safe integer number of seconds. Setting `0`, or an invalid value, produces `Cache-Control: no-store`. Search errors, disabled-endpoint responses, parser errors, and liveness/readiness responses use `no-store`. These headers do not create a cache inside the API, change rate enforcement, or force callers to cache. + +Both search endpoints use POST. Ordinary browser and proxy HTTP caches do not automatically satisfy repeated POST requests from stored POST responses. Integrators must implement application-level result caching, using the freshness header as a hint. Key cached results by API environment, endpoint, and the complete request body, including filters, scope, sorting, and pagination. Never share results merely because requests use the same `/search` URL. `private` does not authorize shared HTTP proxy caching, and the API does not provide conditional POST/304 revalidation. + +Retain a successful result for no longer than the advertised freshness duration, then issue a fresh request. This permits up to that duration of additional staleness after the result was generated; it does not guarantee indexing freshness or consistency across separately cached pages. A cached `/webSearch` response's `queryTime_ms` describes its original execution, not the current lookup. Clients must not cache errors or treat old rate-limit information as a current quota. Only avoiding an HTTP request reduces traffic counted by the deployment's rate limiter; sending a request still counts even if a future server-side cache answers it. + ## Swagger API docs Swagger API docs are regenerated by `npm run dev` and can be regenerated manually using: diff --git a/api-docs/openapi.json b/api-docs/openapi.json index c0ea035..58d3dce 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -34,6 +34,9 @@ } }, "headers": { + "Cache-Control": { + "$ref": "#/components/headers/SearchCacheControl" + }, "X-Request-ID": { "description": "Request correlation ID", "schema": { @@ -44,16 +47,26 @@ }, "400": { "description": "Malformed JSON, invalid request body, parameter combination, or version range", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "$ref": "#/components/schemas/ApiError" } } } }, "413": { "description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -64,6 +77,11 @@ }, "415": { "description": "A non-empty request body did not use the application/json media type with UTF-8 encoding", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -74,6 +92,11 @@ }, "500": { "description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -84,6 +107,11 @@ }, "502": { "description": "The search backend returned an invalid or unexpected response", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -102,6 +130,9 @@ } }, "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + }, "Retry-After": { "description": "Seconds before retrying the unavailable search service", "schema": { @@ -112,6 +143,11 @@ }, "504": { "description": "Search exceeded its processing deadline", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -214,6 +250,9 @@ "200": { "description": "OK", "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + }, "X-Request-ID": { "description": "Request correlation ID", "schema": { @@ -234,6 +273,9 @@ "200": { "description": "API is ready to serve search requests", "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + }, "X-Request-ID": { "description": "Request correlation ID", "schema": { @@ -252,6 +294,9 @@ } }, "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + }, "Retry-After": { "description": "Seconds before retrying the readiness check", "schema": { @@ -288,6 +333,9 @@ } }, "headers": { + "Cache-Control": { + "$ref": "#/components/headers/SearchCacheControl" + }, "X-Request-ID": { "description": "Request correlation ID", "schema": { @@ -298,6 +346,11 @@ }, "400": { "description": "Malformed JSON, unreadable request body, or invalid raw-query request body, sort, or pagination", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -308,6 +361,11 @@ }, "410": { "description": "The endpoint is disabled by service configuration", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -318,6 +376,11 @@ }, "413": { "description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -328,6 +391,11 @@ }, "415": { "description": "Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -338,6 +406,11 @@ }, "500": { "description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -348,6 +421,11 @@ }, "502": { "description": "The search backend returned an error or invalid response", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -358,6 +436,11 @@ }, "504": { "description": "Search exceeded its OpenSearch request deadline", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/NoStoreCacheControl" + } + }, "content": { "application/json": { "schema": { @@ -424,6 +507,24 @@ } }, "components": { + "headers": { + "SearchCacheControl": { + "description": "Client application result-cache freshness hint: private, max-age=60 by default. SearchClientCacheMaxAgeSeconds configures the seconds; zero or invalid values produce no-store. POST results are not automatically reused by ordinary HTTP caches. Clients must key application caches by API environment, endpoint, and the complete request body, including pagination, scope, and sorting. No server-side cache is created.", + "schema": { + "type": "string" + }, + "example": "private, max-age=60" + }, + "NoStoreCacheControl": { + "description": "Do not cache error or health/readiness responses.", + "schema": { + "type": "string", + "enum": [ + "no-store" + ] + } + } + }, "schemas": { "ApiError": { "type": "object", @@ -685,7 +786,7 @@ "type": "string", "maxLength": 4096, "example": "CVE-2024-1234,CVE-2024-5678", - "description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before 1999; those IDs produce no matches and are identified by a non-fatal response warning without preventing other requested IDs from matching. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall." + "description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before 1999; those IDs produce no matches and are identified by a non-fatal response warning (CVE_IDS_NOT_RETURNED) without preventing other requested IDs from matching. Other syntactically valid IDs with no matches do not produce a missing-ID warning. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall." }, "noRejected": { "oneOf": [ @@ -815,28 +916,60 @@ "description": "When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable set to true; false is allowed. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "lastModStartDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-01-01T00:00:00.000Z", - "description": "Returns CVE records with dateUpdated timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns CVE records with dateUpdated timestamps on or after this value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 00:00:00.000 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "lastModEndDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-12-31T23:59:59.999Z", - "description": "Returns CVE records with dateUpdated timestamps on or before this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns CVE records with dateUpdated timestamps on or before this value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 23:59:59.999 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "pubStartDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-01-01T00:00:00.000Z", - "description": "Returns CVE records with datePublished timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns CVE records with datePublished timestamps on or after this value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 00:00:00.000 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "pubEndDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-12-31T23:59:59.999Z", - "description": "Returns CVE records with datePublished timestamps on or before this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns CVE records with datePublished timestamps on or before this value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 23:59:59.999 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "resultsPerPage": { "type": "integer", @@ -919,32 +1052,75 @@ "description": "Returns records with a scope-selected SSVC metric whose role exactly matches this case-insensitive value." }, "ssvcTimestampStartDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-01-01T00:00:00.000Z", - "description": "Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 00:00:00.000 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "ssvcTimestampEndDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-12-31T23:59:59.999Z", - "description": "Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ." + "description": "Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 23:59:59.999 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "knownExploited": { - "type": "boolean", - "description": "When true, returns records with a structured KEV metric in a container selected by scope. When false, excludes records with a selected KEV metric. This field must be true when either KEV date boundary is supplied." + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "When true, returns records with a structured KEV metric in a container selected by scope. When false, excludes records with a selected KEV metric. This field must be true when either KEV date boundary is supplied. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." }, "kevDateAddedStartDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-01-01T00:00:00.000Z", - "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or after this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC." + "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or after this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 00:00:00.000 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "kevDateAddedEndDate": { - "type": "string", - "format": "date-time", + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ], "example": "2024-12-31T23:59:59.999Z", - "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or before this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC." + "description": "Returns records whose scope-selected KEV metric has a dateAdded value on or before this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC. Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means 23:59:59.999 UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected." }, "referenceURL": { "type": "string", @@ -954,8 +1130,19 @@ "description": "Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported. The substring must contain at least 3 characters." }, "disputed": { - "type": "boolean", - "description": "When true, returns records where at least one scope-selected container has the disputed tag. When false, returns records where no selected container has that tag." + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "When true, returns records where at least one scope-selected container has the disputed tag. When false, returns records where no selected container has that tag. JSON booleans and the lowercase strings \"true\" and \"false\" are accepted." } } } diff --git a/api-docs/openapi.test.js b/api-docs/openapi.test.js index a88fbf6..acadad8 100644 --- a/api-docs/openapi.test.js +++ b/api-docs/openapi.test.js @@ -100,15 +100,26 @@ describe('OpenAPI contract', () => { expect(schema.properties.isVulnerable.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); expect(schema.properties.scope.enum).toStrictEqual(['FULL', 'CNA_ONLY', 'ADP_ONLY']); expect(schema.properties.scope.default).toBe('FULL'); + expect(schema.properties.cveIds.description).toContain('CVE_IDS_NOT_RETURNED'); expect(schema.properties.scope.description).toContain('does not remove data'); expect(schema.properties.ssvcExploitation.enum).toStrictEqual(['NONE', 'POC', 'ACTIVE']); expect(schema.properties.ssvcAutomatable.enum).toStrictEqual(['YES', 'NO']); expect(schema.properties.ssvcTechnicalImpact.enum).toStrictEqual(['PARTIAL', 'TOTAL']); - expect(schema.properties.knownExploited.type).toBe('boolean'); + expect(schema.properties.knownExploited.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); expect(schema.properties.kevDateAddedStartDate.description).toContain('midnight UTC'); + for (const field of require('../utils/searchDates').DATE_PARAMS) { + expect(schema.properties[field].oneOf).toStrictEqual([ + { type: 'string', format: 'date' }, + { type: 'string', format: 'date-time' } + ]); + expect(schema.properties[field].description).toContain('3 fractional-second digits'); + expect(schema.properties[field].description).toContain( + field.endsWith('EndDate') ? '23:59:59.999' : '00:00:00.000' + ); + } expect(schema.properties.referenceURL.description).toContain('literal URL substring'); expect(schema.properties.referenceURL.minLength).toBe(constants.MIN_REFERENCE_URL_LENGTH); - expect(schema.properties.disputed.type).toBe('boolean'); + expect(schema.properties.disputed.oneOf).toStrictEqual(schema.properties.noRejected.oneOf); expect(schema.properties.isVulnerable.description).toContain('same CPE Match Criteria entry'); expect(schema.properties.isVulnerable.description).toContain('False or omitted'); expect(schema.properties.cpeName.description).toContain('Maven-style ordering'); @@ -254,6 +265,9 @@ describe('OpenAPI contract', () => { expect(schemas.CveRecord.description).toContain('without search-backend transport metadata'); expect(schemas.ApiError.required).toStrictEqual(['error', 'message']); expect(schemas.ApiErrorResponse.oneOf).toHaveLength(2); + expect(openapi.paths['/search'].post.responses['400'].content['application/json'].schema.$ref).toBe( + '#/components/schemas/ApiError' + ); expect(openapi.paths['/search'].post.responses['200'].content['application/json'].schema.$ref).toBe( '#/components/schemas/SearchResponse' ); @@ -262,6 +276,21 @@ describe('OpenAPI contract', () => { ); }); + test('documents the client freshness policy on successes and no-store on errors and health checks', () => { + for (const path of ['/search', '/webSearch', '/api/health-check', '/api/ready-check']) { + const operation = openapi.paths[path][path.startsWith('/api/') ? 'get' : 'post']; + for (const [status, response] of Object.entries(operation.responses)) { + const successfulSearch = status === '200' && !path.startsWith('/api/'); + expect(response.headers['Cache-Control']).toStrictEqual({ + $ref: `#/components/headers/${successfulSearch ? 'SearchCacheControl' : 'NoStoreCacheControl'}` + }); + } + } + expect(openapi.components.headers.SearchCacheControl.example).toBe('private, max-age=60'); + expect(openapi.components.headers.SearchCacheControl.description).toContain('complete request body'); + expect(openapi.components.headers.NoStoreCacheControl.schema.enum).toStrictEqual(['no-store']); + }); + test('documents request correlation headers', () => { expect(openapi.paths['/search'].post.responses['200'].headers['X-Request-ID']).toStrictEqual({ description: 'Request correlation ID', diff --git a/config/custom-environment-variables.jsonc b/config/custom-environment-variables.jsonc index 265fc61..c4d97cc 100644 --- a/config/custom-environment-variables.jsonc +++ b/config/custom-environment-variables.jsonc @@ -12,6 +12,9 @@ "requestDeadlineMs": "SearchRequestDeadlineMs", "readinessTimeoutMs": "SearchReadinessTimeoutMs" }, + "clientCache": { + "maxAgeSeconds": "SearchClientCacheMaxAgeSeconds" + }, "requestLogging": { "successSampleRate": "RequestLogSuccessSampleRate", "healthChecks": "RequestLogHealthChecks" diff --git a/config/default.jsonc b/config/default.jsonc index cceb5e4..32ec4d0 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -18,6 +18,11 @@ // Overall /api/ready-check deadline and per-operation timeout for field capabilities and search. "readinessTimeoutMs": "5000" }, + // Freshness hint for body-aware client result caches, not an in-process cache. + "clientCache": { + // Zero disables reuse; invalid values also result in no-store. + "maxAgeSeconds": "60" + }, // Access logging settings. Error responses are always logged. "requestLogging": { // Fraction of successful requests to log, including health checks when enabled, from 0 through 1. diff --git a/config/runtime-config.test.js b/config/runtime-config.test.js index c94173e..4822894 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -91,6 +91,7 @@ describe('runtime container and compose configuration', () => { ); expect(customEnvironmentVariables).toContain('"requestDeadlineMs": "SearchRequestDeadlineMs"'); expect(customEnvironmentVariables).toContain('"readinessTimeoutMs": "SearchReadinessTimeoutMs"'); + expect(customEnvironmentVariables).toContain('"maxAgeSeconds": "SearchClientCacheMaxAgeSeconds"'); expect(customEnvironmentVariables).toContain('"successSampleRate": "RequestLogSuccessSampleRate"'); expect(customEnvironmentVariables).toContain('"healthChecks": "RequestLogHealthChecks"'); expect(customEnvironmentVariables).toContain('"webSearchEnabled": "WebSearchEnabled"'); diff --git a/controllers/middleware.js b/controllers/middleware.js index 6b11084..9cc34e0 100644 --- a/controllers/middleware.js +++ b/controllers/middleware.js @@ -4,6 +4,7 @@ const cpe = require('../utils/cpe'); const searchScope = require('../utils/searchScope'); const ssvc = require('../utils/ssvc'); const cvss = require('../utils/cvss'); +const searchDates = require('../utils/searchDates'); const error = new errors.errors(); const cvssV2SeverityEnum = ['LOW', 'MEDIUM', 'HIGH']; @@ -46,22 +47,7 @@ const ssvcEnumValidators = [ const cvssMetricsParams = cvss.PARAMS; -const dateParams = [ - 'lastModStartDate', - 'lastModEndDate', - 'pubStartDate', - 'pubEndDate', - 'ssvcTimestampStartDate', - 'ssvcTimestampEndDate', - 'kevDateAddedStartDate', - 'kevDateAddedEndDate' -]; -const dateRangePairs = [ - ['lastModStartDate', 'lastModEndDate'], - ['pubStartDate', 'pubEndDate'], - ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], - ['kevDateAddedStartDate', 'kevDateAddedEndDate'] -]; +const { DATE_PARAMS: dateParams, DATE_RANGE_PAIRS: dateRangePairs } = searchDates; const versionRangeParams = ['versionStart', 'versionStartType', 'versionEnd', 'versionEndType']; const versionRangeValueParams = ['versionStart', 'versionEnd']; const versionRangeTypeParams = ['versionStartType', 'versionEndType']; @@ -79,7 +65,6 @@ const nonStringSearchParams = [ ]; const nonEmptyStringParams = constants.SEARCH_QUERY_PARAMS.filter((param) => !nonStringSearchParams.includes(param)); -const dateTimeFormat = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/; const cveIdFormat = /^CVE-\d{4}-\d{4,}$/i; const cweIdFormat = /^CWE-[1-9][0-9]*$/i; @@ -157,11 +142,7 @@ function exceedsSearchWindow(params) { } function isValidDateTime(value) { - const input = String(value ?? ''); - if (!dateTimeFormat.test(input)) return false; - - const parsed = new Date(input); - return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === input; + return typeof value === 'string' && searchDates.normalizeDateTime(value) === value; } //Middleware function that will be called on the search endpoint. @@ -386,7 +367,7 @@ function validateParams(req, res, next) { } } - //Ensures date params are valid timestamps matching yyyy-MM-ddTHH:mm:ss.SSS'Z' and real calendar dates. + // Date inputs have already been normalized by the route; reject invalid originals left unchanged. for (const param of dateParams) { if (params?.[param] && !isValidDateTime(params[param])) { return res.status(400).json(error.dateTimeError(param)); diff --git a/controllers/middleware.test.js b/controllers/middleware.test.js index 27ea2a5..e09d613 100644 --- a/controllers/middleware.test.js +++ b/controllers/middleware.test.js @@ -916,7 +916,7 @@ describe('middleware validation function', () => { expectValidationError( 'DATE_TIME_FORMAT', - `The ${dateParam} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + `The ${dateParam} parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.` ); } ); @@ -965,14 +965,14 @@ describe('middleware validation function', () => { ['2024-01-01T00:00Z'], ['2024-01-01T00:00:00Z'], ['2024-01-01T00:00:00.000'] - ])('ValidateParams rejects partial dateTime value %s.', (dateValue) => { + ])('ValidateParams rejects date input that has not been normalized by the route: %s.', (dateValue) => { validate({ pubStartDate: dateValue }); expectValidationError( 'DATE_TIME_FORMAT', - "The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'" + 'The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.' ); }); @@ -988,7 +988,7 @@ describe('middleware validation function', () => { expectValidationError( 'DATE_TIME_FORMAT', - `The ${dateParam} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + `The ${dateParam} parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.` ); }); @@ -1061,7 +1061,7 @@ describe('middleware validation function', () => { validate({ [param]: '2024-02-30T00:00:00.000Z' }); expectValidationError( 'DATE_TIME_FORMAT', - `The ${param} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + `The ${param} parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.` ); } ); diff --git a/controllers/searchController.js b/controllers/searchController.js index 244e15c..c627cbb 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -6,6 +6,7 @@ const searchQueryBuilder = require('../utils/searchQueryBuilder'); const searchPostFilters = require('../utils/searchPostFilters'); const searchResponseBuilder = require('../utils/searchResponseBuilder'); const webSearchQueryBuilder = require('../utils/webSearchQueryBuilder'); +const clientCachePolicy = require('../utils/clientCachePolicy'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); const openSearchUnavailableStatusCodes = new Set([502, 503, 504]); @@ -164,18 +165,15 @@ async function searchHandler(req, res, next) { const pagingOptions = responseOptions ?? searchOptions; const warnings = [...searchResponseBuilder.getRequestWarnings(params), ...searchResult.warnings]; - return res.status(200).json( - searchResponseBuilder.buildSearchResponse({ - ...searchResult, - warnings, - pagingOptions - }) - ); + const body = searchResponseBuilder.buildSearchResponse({ ...searchResult, warnings, pagingOptions }); + clientCachePolicy.setSearchCacheControl(res); + return res.status(200).json(body); } catch (err) { if (searchDeadline.isSearchClientDisconnectedError(err)) { return; } + res.set('Cache-Control', 'no-store'); if (searchDeadline.isSearchTimeoutError(err)) { return res.status(504).json(error.searchTimeoutError()); } @@ -228,8 +226,10 @@ async function webSearchHandler(req, res, next) { body.searchMetadata.notes = request.notes; body.resultsTotal = total; body.data = searchData.hits.hits; + clientCachePolicy.setSearchCacheControl(res); return res.status(200).json(body); } catch (ex) { + res.set('Cache-Control', 'no-store'); const timedOut = searchDeadline.isSearchTimeoutError(ex); const err = timedOut ? error.searchTimeoutError() : error.upstreamSearchError(); body.searchMetadata.errors = [err]; diff --git a/routes/index.js b/routes/index.js index 0d69c3c..9eb2ff4 100644 --- a/routes/index.js +++ b/routes/index.js @@ -8,6 +8,7 @@ const featureFlags = require('../utils/featureFlags'); const searchDeadline = require('../utils/searchDeadline'); const searchProvider = require('../utils/searchProvider'); const searchScope = require('../utils/searchScope'); +const searchDates = require('../utils/searchDates'); const error = new errors.errors(); const SEARCH_BODY_FIELDS = new Set(constants.SEARCH_QUERY_PARAMS); @@ -61,7 +62,8 @@ function parseError(req, res, next) { return msg; }); if (!err.isEmpty()) { - return res.status(400).json(err.array()); + const validationErrors = err.array(); + return res.status(400).json(req.route.path === '/search' ? validationErrors[0] : validationErrors); } next(); } @@ -110,10 +112,6 @@ function isStrictBoolean(value) { return value === true || value === false || value === 'true' || value === 'false'; } -function isJsonBoolean(value) { - return value === true || value === false; -} - function toBooleanValue(value) { if (value === 'true') return true; if (value === 'false') return false; @@ -183,6 +181,14 @@ function normalizeSearchParams(req, res, next) { if (typeof searchParams[param] === 'string') searchParams[param] = searchParams[param].trim().toLowerCase(); }); + searchDates.DATE_RANGE_PAIRS.forEach(([start, end]) => { + for (const field of [start, end]) { + if (searchParams[field] === undefined) continue; + const normalized = searchDates.normalizeDateTime(searchParams[field], { endOfDay: field === end }); + if (normalized !== null) searchParams[field] = normalized; + } + }); + req.searchParams = searchParams; next(); } @@ -292,6 +298,7 @@ content: { } }, headers: { + "Cache-Control": { $ref: "#/components/headers/SearchCacheControl" }, "X-Request-ID": { description: "Request correlation ID", schema: { @@ -302,16 +309,18 @@ headers: { } #swagger.responses[400] = { description: 'Malformed JSON, invalid request body, parameter combination, or version range', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { - $ref: "#/components/schemas/ApiErrorResponse" + $ref: "#/components/schemas/ApiError" } } }, } #swagger.responses[413] = { description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -320,6 +329,7 @@ content: { } #swagger.responses[500] = { description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -328,6 +338,7 @@ content: { } #swagger.responses[415] = { description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -338,6 +349,7 @@ content: { } #swagger.responses[502] = { description: 'The search backend returned an invalid or unexpected response', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -356,6 +368,7 @@ content: { } }, headers: { + "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" }, "Retry-After": { description: "Seconds before retrying the unavailable search service", schema: { @@ -366,6 +379,7 @@ headers: { } #swagger.responses[504] = { description: 'Search exceeded its processing deadline', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -395,10 +409,24 @@ content: { .custom(isStrictBoolean) .withMessage(error.noRejectedValueError()) .customSanitizer(toBooleanValue), - body('knownExploited').optional().custom(isJsonBoolean).withMessage(error.knownExploitedInputError()), - body('disputed').optional().custom(isJsonBoolean).withMessage(error.disputedInputError()), + body('knownExploited') + .optional() + .custom(isStrictBoolean) + .withMessage(error.knownExploitedInputError()) + .customSanitizer(toBooleanValue), + body('disputed') + .optional() + .custom(isStrictBoolean) + .withMessage(error.disputedInputError()) + .customSanitizer(toBooleanValue), body(['resultsPerPage']) .optional() + .custom((value) => typeof value === 'number' || typeof value === 'string') + .withMessage(error.resultsPerPageError()) + .bail() + .isInt({ min: 1 }) + .withMessage(error.resultsPerPageError()) + .bail() .isInt({ max: constants.MAX_RESULTS_LIMIT }) .withMessage(error.maxResultsError()) .toInt(), @@ -419,6 +447,7 @@ router.get( #swagger.responses[200] = { description: 'OK', headers: { + "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" }, "X-Request-ID": { description: "Request correlation ID", schema: { @@ -441,6 +470,7 @@ router.get( #swagger.responses[200] = { description: 'API is ready to serve search requests', headers: { + "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" }, "X-Request-ID": { description: "Request correlation ID", schema: { @@ -459,6 +489,7 @@ content: { } }, headers: { + "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" }, "Retry-After": { description: "Seconds before retrying the readiness check", schema: { @@ -539,6 +570,7 @@ content: { } }, headers: { + "Cache-Control": { $ref: "#/components/headers/SearchCacheControl" }, "X-Request-ID": { description: "Request correlation ID", schema: { @@ -549,6 +581,7 @@ headers: { } #swagger.responses[400] = { description: 'Malformed JSON, unreadable request body, or invalid raw-query request body, sort, or pagination', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -559,6 +592,7 @@ content: { } #swagger.responses[504] = { description: 'Search exceeded its OpenSearch request deadline', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -569,6 +603,7 @@ content: { } #swagger.responses[413] = { description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -577,6 +612,7 @@ content: { } #swagger.responses[415] = { description: 'Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -585,6 +621,7 @@ content: { } #swagger.responses[500] = { description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } @@ -593,6 +630,7 @@ content: { } #swagger.responses[410] = { description: 'The endpoint is disabled by service configuration', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { @@ -603,6 +641,7 @@ content: { } #swagger.responses[502] = { description: 'The search backend returned an error or invalid response', +headers: { "Cache-Control": { $ref: "#/components/headers/NoStoreCacheControl" } }, content: { "application/json": { schema: { diff --git a/routes/index.test.js b/routes/index.test.js index 9be9c4d..065f4eb 100644 --- a/routes/index.test.js +++ b/routes/index.test.js @@ -34,6 +34,7 @@ const mw = require('../controllers/middleware'); const controller = require('../controllers/searchController'); const featureFlags = require('../utils/featureFlags'); const router = require('./index'); +const { DATE_RANGE_PAIRS } = require('../utils/searchDates'); describe('routes/index router', () => { let app; @@ -123,6 +124,30 @@ describe('routes/index router', () => { }); }); + test.each(DATE_RANGE_PAIRS)('normalizes date-only %s and %s without changing the body', async (start, end) => { + const body = { [start]: '2024-02-29', [end]: '2024-02-29' }; + const response = await request(app, { method: 'POST', path: '/search', body }); + expect(response.status).toBe(200); + expect(response.body.params).toMatchObject({ + [start]: '2024-02-29T00:00:00.000Z', + [end]: '2024-02-29T23:59:59.999Z' + }); + expect(mw.validateParams.mock.calls[0][0].body).toStrictEqual(body); + }); + + test.each(DATE_RANGE_PAIRS)('normalizes offsets for %s and %s', async (start, end) => { + const response = await request(app, { + method: 'POST', + path: '/search', + body: { [start]: '2024-01-01T00:00:00+05:30', [end]: '2024-01-01T00:00:00.12Z' } + }); + expect(response.status).toBe(200); + expect(response.body.params).toMatchObject({ + [start]: '2023-12-31T18:30:00.000Z', + [end]: '2024-01-01T00:00:00.120Z' + }); + }); + test('POST /search rejects a non-empty body with an unsupported media type before validation', async () => { const response = await request(app, { method: 'POST', @@ -178,12 +203,10 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'EXACT_MATCH_BOOLEAN', - message: 'The keywordExactMatch parameter must be a boolean.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'EXACT_MATCH_BOOLEAN', + message: 'The keywordExactMatch parameter must be a boolean.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -200,12 +223,10 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'SEARCH_FIELD_STRING', - message: 'The keywordSearch field must be a non-empty string.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'SEARCH_FIELD_STRING', + message: 'The keywordSearch field must be a non-empty string.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -220,12 +241,10 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'SEARCH_FIELD_STRING', - message: 'The cpeName field must be a non-empty string.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'SEARCH_FIELD_STRING', + message: 'The cpeName field must be a non-empty string.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -240,12 +259,10 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'SEARCH_FIELD_STRING', - message: 'The referenceURL field must be a non-empty string.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'SEARCH_FIELD_STRING', + message: 'The referenceURL field must be a non-empty string.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); @@ -271,6 +288,24 @@ describe('routes/index router', () => { }); }); + test.each(['/search', '/search/', '/SEARCH'])( + 'POST %s returns one error object when multiple validations fail', + async (path) => { + const response = await request(app, { + method: 'POST', + path, + body: { keywordSearch: [], keywordExactMatch: 'invalid', resultsPerPage: 501 } + }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + error: 'SEARCH_FIELD_STRING', + message: 'The keywordSearch field must be a non-empty string.' + }); + expect(controller.SEARCH).not.toHaveBeenCalled(); + } + ); + test('POST /search normalizes noRejected false as omitted', async () => { const response = await request(app, { method: 'POST', @@ -312,31 +347,26 @@ describe('routes/index router', () => { }); }); - test('POST /search rejects a string value for knownExploited', async () => { - const response = await request(app, { - method: 'POST', - path: '/search', - body: { - knownExploited: 'true' - } - }); - - expect(response.status).toBe(400); - expect(controller.SEARCH).not.toHaveBeenCalled(); - }); - - test('POST /search rejects a string value for disputed', async () => { - const response = await request(app, { - method: 'POST', - path: '/search', - body: { - disputed: 'false' - } - }); - - expect(response.status).toBe(400); - expect(controller.SEARCH).not.toHaveBeenCalled(); - }); + describe.each(['isVulnerable', 'keywordExactMatch', 'noRejected', 'knownExploited', 'disputed'])( + '%s boolean input', + (field) => { + test.each([true, false, 'true', 'false'])('normalizes %j', async (value) => { + const response = await request(app, { method: 'POST', path: '/search', body: { [field]: value } }); + expect(response.status).toBe(200); + const expected = value === true || value === 'true'; + if (field === 'noRejected' && !expected) expect(response.body.params).not.toHaveProperty(field); + else expect(response.body.params[field]).toBe(expected); + }); + + test.each([null, 0, 1, [], ['true'], {}, 'TRUE', 'False', 'yes', ''])('rejects %j', async (value) => { + const response = await request(app, { method: 'POST', path: '/search', body: { [field]: value } }); + expect(response.status).toBe(400); + expect(Array.isArray(response.body)).toBe(false); + expect(mw.validateParams).not.toHaveBeenCalled(); + expect(controller.SEARCH).not.toHaveBeenCalled(); + }); + } + ); test('POST /search rejects resultsPerPage above the configured max before custom middleware runs', async () => { const response = await request(app, { @@ -348,16 +378,34 @@ describe('routes/index router', () => { }); expect(response.status).toBe(400); - expect(response.body).toStrictEqual([ - { - error: 'MAX_RESULTS_LIMIT', - message: 'The resultsPerPage must be an integer less than or equal to 500.' - } - ]); + expect(response.body).toStrictEqual({ + error: 'MAX_RESULTS_LIMIT', + message: 'The resultsPerPage must be an integer less than or equal to 500.' + }); expect(mw.validateParams).not.toHaveBeenCalled(); expect(controller.SEARCH).not.toHaveBeenCalled(); }); + test.each(['abc', 1.5, '1.5', 0, -1, null, true, false, [], [1], {}])( + 'POST /search rejects invalid page size %j with the integer error', + async (resultsPerPage) => { + const response = await request(app, { method: 'POST', path: '/search', body: { resultsPerPage } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('RESULTS_PER_PAGE_INT'); + expect(mw.validateParams).not.toHaveBeenCalled(); + expect(controller.SEARCH).not.toHaveBeenCalled(); + } + ); + + test.each([1, 500, '1', '500'])( + 'POST /search normalizes valid page size %j without truncation', + async (resultsPerPage) => { + const response = await request(app, { method: 'POST', path: '/search', body: { resultsPerPage } }); + expect(response.status).toBe(200); + expect(response.body.params.resultsPerPage).toBe(Number(resultsPerPage)); + } + ); + test('POST /webSearch parses JSON body and reaches controller', async () => { const response = await request(app, { method: 'POST', diff --git a/routes/search.integration.test.js b/routes/search.integration.test.js index cd172a7..1b8dddc 100644 --- a/routes/search.integration.test.js +++ b/routes/search.integration.test.js @@ -28,6 +28,57 @@ describe('search route integration', () => { mockSearchOpenSearch.mockReset(); }); + test.each([ + ['/search', { keywordSearch: 'apache' }, 0], + ['/search', { keywordSearch: 'apache' }, 1], + ['/webSearch', { query: 'apache' }, 0], + ['/webSearch', { query: 'apache' }, 1], + ['/SEARCH/', { keywordSearch: 'apache' }, 0], + ['/WEBSEARCH/', { query: 'apache' }, 0] + ])('%s advertises client freshness for successful results, including %s hits', async (path, body, total) => { + const hits = total ? [{ _source: { cveMetadata: { cveId: 'CVE-2024-0001' } } }] : []; + mockSearchOpenSearch.mockResolvedValue(advancedResponse(total, hits).data); + const response = await request(createApp(), { method: 'POST', path, body }); + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('private, max-age=60'); + }); + + test.each([ + ['/search', { resultsPerPage: 0 }], + ['/webSearch', { query: '' }] + ])('%s does not cache validation errors', async (path, body) => { + const response = await request(createApp(), { method: 'POST', path, body }); + expect(response.status).toBe(400); + expect(response.headers['cache-control']).toBe('no-store'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([ + ['/search', { name: 'TimeoutError' }, 504], + ['/search', { name: 'ConnectionError' }, 503], + ['/search', new Error('backend failure'), 502], + ['/webSearch', { name: 'TimeoutError' }, 504], + ['/webSearch', new Error('backend failure'), 502] + ])('%s does not cache upstream errors: %p', async (path, error, status) => { + mockSearchOpenSearch.mockRejectedValue(error); + const body = path === '/search' ? { keywordSearch: 'apache' } : { query: 'apache' }; + const response = await request(createApp(), { method: 'POST', path, body }); + expect(response.status).toBe(status); + expect(response.headers['cache-control']).toBe('no-store'); + }); + + test.each(['/search', '/webSearch'])('%s does not cache parser errors', async (path) => { + const response = await request(createApp(), { + method: 'POST', + path, + rawBody: '{', + headers: { 'Content-Type': 'application/json' } + }); + expect(response.status).toBe(400); + expect(response.headers['cache-control']).toBe('no-store'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + test.each( [ '79', @@ -79,7 +130,89 @@ describe('search route integration', () => { async (body) => { const response = await request(createApp(), { method: 'POST', path: '/search', body }); expect(response.status).toBe(400); - expect(response.body[0].error).toBe('INVALID_PARAM'); + expect(response.body.error).toBe('INVALID_PARAM'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + } + ); + + test.each([ + ['CVE-2099-99999999', false, []], + ['cve-2024-0001,CVE-2099-99999999,CVE-2099-99999999', true, []], + [ + 'CVE-1998-0001,cve-2024-0001,CVE-2099-99999999', + true, + [expect.objectContaining({ code: 'CVE_IDS_NOT_RETURNED', cveIds: ['CVE-1998-0001'] })] + ] + ])('returns matches without an availability lookup for %s', async (cveIds, hasMatch, warnings) => { + const record = { cveMetadata: { cveId: 'CVE-2024-0001' } }; + const records = hasMatch ? [record] : []; + mockSearchOpenSearch.mockResolvedValue( + advancedResponse( + records.length, + records.map((_source) => ({ _source })) + ).data + ); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { cveIds, resultsPerPage: 1 } + }); + expect(response.status).toBe(200); + expect(response.body.records).toStrictEqual(records); + expect(response.body.pagination.totalResults).toBe(records.length); + expect(response.body.warnings).toStrictEqual(warnings); + expect(Object.keys(response.body).indexOf('warnings')).toBeLessThan( + Object.keys(response.body).indexOf('records') + ); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + }); + + test.each([ + { resultsPerPage: 1, pageNumber: 2 }, + { keywordSearch: 'nonmatching-text', scope: 'ADP_ONLY' }, + { noRejected: true }, + { virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' } + ])('does not perform an availability lookup when filters or pagination exclude an ID: %p', async (filters) => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { cveIds: 'CVE-2024-0001', ...filters } + }); + expect(response.status).toBe(200); + expect(response.body.records).toStrictEqual([]); + expect(response.body.warnings).toStrictEqual([]); + expect(mockSearchOpenSearch).toHaveBeenCalledTimes(1); + }); + + test('date-only boundaries and offsets reach OpenSearch as inclusive UTC ranges', async () => { + mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { + pubStartDate: '2024-02-29', + pubEndDate: '2024-02-29', + lastModStartDate: '2024-03-01T01:00:00+01:00' + } + }); + expect(response.status).toBe(200); + expect(mockSearchOpenSearch.mock.calls[0][0].query.bool.filter).toStrictEqual([ + { + range: { + 'cveMetadata.datePublished': { gte: '2024-02-29T00:00:00.000Z', lte: '2024-02-29T23:59:59.999Z' } + } + }, + { range: { 'cveMetadata.dateUpdated': { gte: '2024-03-01T00:00:00.000Z', lte: null } } } + ]); + }); + + test.each(['2024-02-30', '2023-02-29T00:00:00+01:00', '2024-01-01T00:00:00.1234Z', '2024-01-01T00:00:00'])( + 'rejects invalid or unsupported date %s before OpenSearch', + async (pubStartDate) => { + const response = await request(createApp(), { method: 'POST', path: '/search', body: { pubStartDate } }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('DATE_TIME_FORMAT'); expect(mockSearchOpenSearch).not.toHaveBeenCalled(); } ); @@ -151,6 +284,42 @@ describe('search route integration', () => { expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); + test('compares date range order after timezone normalization', async () => { + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { pubStartDate: '2024-01-01T01:00:00-02:00', pubEndDate: '2024-01-01T02:00:00Z' } + }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('DATE_RANGE_ORDER'); + expect(mockSearchOpenSearch).not.toHaveBeenCalled(); + }); + + test.each([ + ['ssvc', 'ssvcTimestampStartDate', 'ssvcTimestampEndDate', { timestamp: '2024-02-29T12:00:00Z' }], + ['kev', 'kevDateAddedStartDate', 'kevDateAddedEndDate', { dateAdded: '2024-02-29' }] + ])('date-only %s boundaries include a metric on the requested day', async (type, start, end, content) => { + const hit = { + _id: 'CVE-2024-0001', + _source: { + cveMetadata: { cveId: 'CVE-2024-0001' }, + containers: { cna: { metrics: [{ other: { type, content } }] } } + } + }; + mockSearchOpenSearch.mockResolvedValue(advancedResponse(1, [hit]).data); + const response = await request(createApp(), { + method: 'POST', + path: '/search', + body: { [start]: '2024-02-29', [end]: '2024-02-29', ...(type === 'kev' ? { knownExploited: 'true' } : {}) } + }); + expect(response.status).toBe(200); + expect(response.body.pagination.totalResults).toBe(1); + expect(response.body.records[0].cveMetadata.cveId).toBe('CVE-2024-0001'); + const candidate = JSON.stringify(mockSearchOpenSearch.mock.calls[0][0]); + expect(candidate).toContain('2024-02-29T00:00:00.000Z'); + expect(candidate).toContain('2024-02-29T23:59:59.999Z'); + }); + test('POST /search validates, builds the OpenSearch request, and formats pagination metadata', async () => { const record = { dataType: 'CVE_RECORD', @@ -374,7 +543,8 @@ describe('search route integration', () => { expect(response.status).toBe(400); expect(response.body).toStrictEqual({ error: 'DATE_TIME_FORMAT', - message: "The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'" + message: + 'The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.' }); expect(mockSearchOpenSearch).not.toHaveBeenCalled(); }); diff --git a/routes/swagger.js b/routes/swagger.js index 0195355..f93086a 100644 --- a/routes/swagger.js +++ b/routes/swagger.js @@ -35,6 +35,19 @@ function coercibleBooleanSchema(defaultValue = false) { }; } +function searchDateProperty(description, endOfDay) { + return searchRequestProperty( + `${description} Accepts YYYY-MM-DD or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and up to 3 fractional-second digits. Values are normalized to UTC. A date-only boundary means ${endOfDay ? '23:59:59.999' : '00:00:00.000'} UTC on that day; boundaries are inclusive. Impossible dates, timezone-less timestamps, and finer precision are rejected.`, + { + oneOf: [ + { type: 'string', format: 'date' }, + { type: 'string', format: 'date-time' } + ], + example: endOfDay ? '2024-12-31T23:59:59.999Z' : '2024-01-01T00:00:00.000Z' + } + ); +} + const searchRequestProperties = { scope: searchRequestProperty( 'Controls which containers participate in filter matching. FULL searches base record fields, CNA data, and all ADP providers. CNA_ONLY searches base record fields and CNA data. ADP_ONLY searches base record fields and all ADP providers. Scope does not remove data from returned records. Values are case-insensitive. CPE applicability filters are not available with ADP_ONLY.', @@ -57,7 +70,7 @@ const searchRequestProperties = { coercibleBooleanSchema() ), cveIds: searchRequestProperty( - `Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before ${constants.FIRST_CVE_RECORD_YEAR}; those IDs produce no matches and are identified by a non-fatal response warning without preventing other requested IDs from matching. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`, + `Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before ${constants.FIRST_CVE_RECORD_YEAR}; those IDs produce no matches and are identified by a non-fatal response warning (CVE_IDS_NOT_RETURNED) without preventing other requested IDs from matching. Other syntactically valid IDs with no matches do not produce a missing-ID warning. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`, { type: 'string', maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cveIds, @@ -163,38 +176,19 @@ const searchRequestProperties = { 'When true, returns CVE records where the same CPE Match Criteria entry both matches cpeName and is marked vulnerable. False or omitted does not apply a vulnerability restriction. cpeName is required when isVulnerable is true. virtualMatchString cannot be provided with isVulnerable set to true; false is allowed. JSON booleans and the lowercase strings "true" and "false" are accepted.', coercibleBooleanSchema() ), - lastModStartDate: searchRequestProperty( - 'Returns CVE records with dateUpdated timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-01-01T00:00:00.000Z' - } - ), - lastModEndDate: searchRequestProperty( - 'Returns CVE records with dateUpdated timestamps on or before this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-12-31T23:59:59.999Z' - } + lastModStartDate: searchDateProperty( + 'Returns CVE records with dateUpdated timestamps on or after this value.', + false ), - pubStartDate: searchRequestProperty( - 'Returns CVE records with datePublished timestamps on or after this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-01-01T00:00:00.000Z' - } + lastModEndDate: searchDateProperty( + 'Returns CVE records with dateUpdated timestamps on or before this value.', + true ), - pubEndDate: searchRequestProperty( - 'Returns CVE records with datePublished timestamps on or before this value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-12-31T23:59:59.999Z' - } + pubStartDate: searchDateProperty( + 'Returns CVE records with datePublished timestamps on or after this value.', + false ), + pubEndDate: searchDateProperty('Returns CVE records with datePublished timestamps on or before this value.', true), resultsPerPage: searchRequestProperty( `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.`, { @@ -281,43 +275,25 @@ const searchRequestProperties = { example: 'CISA Coordinator' } ), - ssvcTimestampStartDate: searchRequestProperty( - 'Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-01-01T00:00:00.000Z' - } + ssvcTimestampStartDate: searchDateProperty( + 'Returns records with a scope-selected SSVC metric timestamp on or after this inclusive value.', + false ), - ssvcTimestampEndDate: searchRequestProperty( - 'Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value. Use the format yyyy-MM-ddTHH:mm:ss.SSSZ.', - { - type: 'string', - format: 'date-time', - example: '2024-12-31T23:59:59.999Z' - } + ssvcTimestampEndDate: searchDateProperty( + 'Returns records with a scope-selected SSVC metric timestamp on or before this inclusive value.', + true ), knownExploited: searchRequestProperty( - 'When true, returns records with a structured KEV metric in a container selected by scope. When false, excludes records with a selected KEV metric. This field must be true when either KEV date boundary is supplied.', - { - type: 'boolean' - } + 'When true, returns records with a structured KEV metric in a container selected by scope. When false, excludes records with a selected KEV metric. This field must be true when either KEV date boundary is supplied. JSON booleans and the lowercase strings "true" and "false" are accepted.', + { oneOf: coercibleBooleanSchema().oneOf } ), - kevDateAddedStartDate: searchRequestProperty( + kevDateAddedStartDate: searchDateProperty( 'Returns records whose scope-selected KEV metric has a dateAdded value on or after this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC.', - { - type: 'string', - format: 'date-time', - example: '2024-01-01T00:00:00.000Z' - } + false ), - kevDateAddedEndDate: searchRequestProperty( + kevDateAddedEndDate: searchDateProperty( 'Returns records whose scope-selected KEV metric has a dateAdded value on or before this inclusive timestamp. knownExploited must be true. Stored date-only values are interpreted as midnight UTC.', - { - type: 'string', - format: 'date-time', - example: '2024-12-31T23:59:59.999Z' - } + true ), referenceURL: searchRequestProperty( `Returns records whose scope-selected references contain this case-insensitive literal URL substring. Wildcard syntax is not supported. The substring must contain at least ${constants.MIN_REFERENCE_URL_LENGTH} characters.`, @@ -329,10 +305,8 @@ const searchRequestProperties = { } ), disputed: searchRequestProperty( - 'When true, returns records where at least one scope-selected container has the disputed tag. When false, returns records where no selected container has that tag.', - { - type: 'boolean' - } + 'When true, returns records where at least one scope-selected container has the disputed tag. When false, returns records where no selected container has that tag. JSON booleans and the lowercase strings "true" and "false" are accepted.', + { oneOf: coercibleBooleanSchema().oneOf } ) }; @@ -354,6 +328,18 @@ const doc = { } ], components: { + headers: { + SearchCacheControl: { + description: + 'Client application result-cache freshness hint: private, max-age=60 by default. SearchClientCacheMaxAgeSeconds configures the seconds; zero or invalid values produce no-store. POST results are not automatically reused by ordinary HTTP caches. Clients must key application caches by API environment, endpoint, and the complete request body, including pagination, scope, and sorting. No server-side cache is created.', + schema: { type: 'string' }, + example: 'private, max-age=60' + }, + NoStoreCacheControl: { + description: 'Do not cache error or health/readiness responses.', + schema: { type: 'string', enum: ['no-store'] } + } + }, '@schemas': { ApiError: { type: 'object', diff --git a/server.js b/server.js index 6cc0233..cca6f97 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ const logger = require('morgan'); const router = require('./routes/index'); const errors = require('./utils/errors'); const requestLogging = require('./utils/requestLogging'); +const clientCachePolicy = require('./utils/clientCachePolicy'); const error = new errors.errors(); @@ -63,6 +64,7 @@ function createApp() { app.use(requestLogging.assignRequestId); app.use(createRequestLogger()); + app.use(['/search', '/webSearch', '/api/health-check', '/api/ready-check'], clientCachePolicy.noStore); app.use(cors()); app.use(express.json()); diff --git a/server.test.js b/server.test.js index 7b0bcac..39efec9 100644 --- a/server.test.js +++ b/server.test.js @@ -70,6 +70,7 @@ describe('server application bootstrap', () => { expect(response.text).toBe(''); expect(response.headers['access-control-allow-origin']).toBe('*'); expect(response.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/); + expect(response.headers['cache-control']).toBe('no-store'); }); test('createApp preserves valid caller request IDs in response headers', async () => { @@ -83,6 +84,23 @@ describe('server application bootstrap', () => { expect(response.headers['x-request-id']).toBe('caller-request-123'); }); + test.each([true, false])('readiness responses are not cacheable when ready=%s', async (ready) => { + const check = jest.spyOn(require('./utils/searchProvider'), 'checkOpenSearchReadiness'); + if (ready) check.mockResolvedValue(undefined); + else check.mockRejectedValue(new Error('unavailable')); + + const response = await request(createApp(), { path: '/api/ready-check' }); + expect(response.status).toBe(ready ? 200 : 503); + expect(response.headers['cache-control']).toBe('no-store'); + }); + + test('disabled webSearch responses are not cacheable', async () => { + jest.spyOn(require('./utils/featureFlags'), 'isWebSearchEnabled').mockReturnValue(false); + const response = await request(createApp(), { method: 'POST', path: '/webSearch', body: {} }); + expect(response.status).toBe(410); + expect(response.headers['cache-control']).toBe('no-store'); + }); + test('createApp mounts the OpenAPI JSON endpoint', async () => { const response = await request(createApp(), { path: '/api-docs/openapi.json' diff --git a/test/postman/CVE-Search-API.postman_collection.json b/test/postman/CVE-Search-API.postman_collection.json index 3000add..3fdbbdf 100644 --- a/test/postman/CVE-Search-API.postman_collection.json +++ b/test/postman/CVE-Search-API.postman_collection.json @@ -427,6 +427,218 @@ }, "response": [] }, + { + "name": "knownExploited accepts string true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "}" + ] + } + } + ], + "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 \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": \"true\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "knownExploited accepts string false", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "}" + ] + } + } + ], + "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 \"cveIds\": \"CVE-2024-5981\",\n \"knownExploited\": \"false\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed accepts string true", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "}" + ] + } + } + ], + "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 \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": \"true\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "disputed accepts string false", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "}" + ] + } + } + ], + "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 \"cveIds\": \"CVE-2024-5981\",\n \"disputed\": \"false\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "Second valid page can be empty", "event": [ @@ -1960,6 +2172,168 @@ "name": "05 - Record date filters", "description": "Publication and modification boundaries.", "item": [ + { + "name": "Date-only publication boundaries", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + "}" + ] + } + } + ], + "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 \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01\",\n \"pubEndDate\": \"2024-12-31\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Publication timestamp with explicit offset", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + "}" + ] + } + } + ], + "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 \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T01:00:00+01:00\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Publication timestamp without fractional seconds", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', () => pm.response.to.have.status(200));", + "pm.test('X-Request-ID is present', () => {", + " pm.expect(pm.response.headers.get('X-Request-ID')).to.be.a('string').and.not.empty;", + "});", + "const isExpectedSuccess = pm.response.code === 200;", + "if (isExpectedSuccess) {", + " pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", + " const payload = pm.response.json();", + "pm.test('search response has the documented shape', () => {", + " pm.expect(payload.apiVersion).to.eql('1.0');", + " pm.expect(payload.status).to.eql('ok');", + " pm.expect(payload.warnings).to.be.an('array');", + " pm.expect(payload.pagination).to.be.an('object');", + " pm.expect(payload.pagination.page).to.be.a('number');", + " pm.expect(payload.pagination.pageSize).to.be.a('number');", + " pm.expect(payload.pagination.resultsReturned).to.be.a('number');", + " pm.expect(payload.pagination.totalResults).to.be.a('number');", + " pm.expect(payload.pagination.totalPages).to.be.a('number');", + " pm.expect(payload.pagination.totalIsExact).to.be.a('boolean');", + " pm.expect(payload.records).to.be.an('array');", + " pm.expect(payload.pagination.resultsReturned).to.eql(payload.records.length);", + " pm.expect(payload).not.to.have.property('data');", + "});", + "pm.test('total is 1', () => pm.expect(payload.pagination.totalResults).to.eql(1));", + "}" + ] + } + } + ], + "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 \"cveIds\": \"CVE-2024-5981\",\n \"pubStartDate\": \"2024-01-01T00:00:00Z\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "pubStartDate", "event": [ @@ -4029,6 +4403,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_QUERY_UNSUPPORTED\",\"message\":\"The /search endpoint accepts JSON request body fields, not query parameters.\"};", "pm.test('expected error code is returned', () => {", @@ -4069,6 +4444,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_CONTENT_TYPE\",\"message\":\"The /search request body must use the application/json media type with UTF-8 encoding.\"};", "pm.test('expected error code is returned', () => {", @@ -4109,6 +4485,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", "pm.test('expected error code is returned', () => {", @@ -4153,6 +4530,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_BODY_OBJECT\",\"message\":\"The /search request body must be a JSON object.\"};", "pm.test('expected error code is returned', () => {", @@ -4193,6 +4571,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"MALFORMED_JSON\",\"message\":\"The request body contains malformed JSON.\"};", "pm.test('expected error code is returned', () => {", @@ -4233,6 +4612,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"unsupportedFilter is not supported by the Search API.\"};", "pm.test('expected error code is returned', () => {", @@ -4277,6 +4657,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The keywordSearch field must be a non-empty string.\"};", "pm.test('expected error code is returned', () => {", @@ -4321,6 +4702,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_FIELD_STRING\",\"message\":\"The scope field must be a non-empty string.\"};", "pm.test('expected error code is returned', () => {", @@ -4371,6 +4753,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_FIELD_LENGTH\",\"message\":\"The keywordSearch field must be 512 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", @@ -4415,6 +4798,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_FIELD_TOKEN_LIMIT\",\"message\":\"The keywordSearch field must contain 64 or fewer terms.\"};", "pm.test('expected error code is returned', () => {", @@ -4459,6 +4843,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_FIELD_MINIMUM_LENGTH\",\"message\":\"The referenceURL field must be at least 3 characters.\"};", "pm.test('expected error code is returned', () => {", @@ -4503,6 +4888,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVE_IDS_FORMAT\",\"message\":\"The cveIds parameter must be a comma-separated list of CVE IDs.\"};", "pm.test('expected error code is returned', () => {", @@ -4547,6 +4933,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", @@ -4591,6 +4978,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", @@ -4635,6 +5023,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", @@ -4679,6 +5068,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", @@ -4723,6 +5113,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", @@ -4767,6 +5158,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", @@ -4811,6 +5203,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", @@ -4855,6 +5248,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_FORMAT\",\"message\":\"The cweIds parameter must be a comma-separated list of CWE IDs in the format CWE-, without leading zeros (for example, CWE-79,CWE-89).\"};", "pm.test('expected error code is returned', () => {", @@ -4899,6 +5293,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_PARAM\",\"message\":\"cweId is not supported by the Search API.\"};", "pm.test('expected error code is returned', () => {", @@ -4943,6 +5338,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_ID_LENGTH\",\"message\":\"Each CWE ID in the cweIds field must be 32 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", @@ -4987,6 +5383,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CWE_IDS_LIMIT\",\"message\":\"The cweIds parameter cannot contain more than 100 CWE IDs.\"};", "pm.test('expected error code is returned', () => {", @@ -5031,6 +5428,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVE_ID_LENGTH\",\"message\":\"Each CVE ID in the cveIds field must be 64 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", @@ -5075,6 +5473,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVE_IDS_LIMIT\",\"message\":\"The cveIds parameter cannot contain more than 100 CVE IDs.\"};", "pm.test('expected error code is returned', () => {", @@ -5119,6 +5518,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", "pm.test('expected error code is returned', () => {", @@ -5149,6 +5549,231 @@ }, "response": [] }, + { + "name": "Invalid page size \"abc\"", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "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 \"resultsPerPage\": \"abc\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid page size 1.5", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "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 \"resultsPerPage\": 1.5\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid page size 0", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "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 \"resultsPerPage\": 0\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid page size true", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "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 \"resultsPerPage\": true\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Invalid page size [1]", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"RESULTS_PER_PAGE_INT\",\"message\":\"The resultsPerPage parameter must be an integer with a value greater than 0.\"};", + "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 \"resultsPerPage\": [\n 1\n ]\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, { "name": "resultsPerPage maximum", "event": [ @@ -5163,6 +5788,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"MAX_RESULTS_LIMIT\",\"message\":\"The resultsPerPage must be an integer less than or equal to 500.\"};", "pm.test('expected error code is returned', () => {", @@ -5207,6 +5833,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"PAGE_NUMBER_INT\",\"message\":\"The pageNumber parameter must be an integer with a value greater than 0.\"};", "pm.test('expected error code is returned', () => {", @@ -5251,6 +5878,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_WINDOW_LIMIT\",\"message\":\"The requested pagination window exceeds the maximum search window of 10000 results. Reduce pagination depth or page size.\"};", "pm.test('expected error code is returned', () => {", @@ -5295,6 +5923,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV3Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", @@ -5339,6 +5968,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV4Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", @@ -5383,6 +6013,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVSS_METRICS_LIMIT\",\"message\":\"The cvssV2Metrics parameter must be 256 characters or fewer, contain 32 or fewer slash-delimited metric tokens, and each metric token must be 32 characters or fewer.\"};", "pm.test('expected error code is returned', () => {", @@ -5427,6 +6058,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3Metrics parameter is invalid. The CVSS version prefix must match the requested filter.\"};", "pm.test('expected error code is returned', () => {", @@ -5471,6 +6103,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV4Metrics parameter is invalid. A metric name or value is not supported by the requested CVSS version.\"};", "pm.test('expected error code is returned', () => {", @@ -5515,6 +6148,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV3_1Metrics parameter is invalid. Supply one or more slash-delimited metric:value tokens without empty segments.\"};", "pm.test('expected error code is returned', () => {", @@ -5559,6 +6193,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"CVSS_METRICS_FORMAT\",\"message\":\"The cvssV2Metrics parameter is invalid. A metric cannot have conflicting values in the same filter.\"};", "pm.test('expected error code is returned', () => {", @@ -5609,6 +6244,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"IS_VULN_BOOLEAN\",\"message\":\"The isVulnerable parameter must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -5653,6 +6289,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"EXACT_MATCH_BOOLEAN\",\"message\":\"The keywordExactMatch parameter must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -5697,6 +6334,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"NO_REJECTED_VALUE\",\"message\":\"The noRejected field must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -5741,6 +6379,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"KNOWN_EXPLOITED_BOOLEAN\",\"message\":\"The knownExploited parameter must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -5765,7 +6404,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"knownExploited\": \"true\"\n}", + "raw": "{\n \"knownExploited\": \"yes\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -5785,6 +6424,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"DISPUTED_BOOLEAN\",\"message\":\"The disputed parameter must be a boolean.\"};", "pm.test('expected error code is returned', () => {", @@ -5809,7 +6449,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"disputed\": \"false\"\n}", + "raw": "{\n \"disputed\": \"no\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -5829,6 +6469,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SCOPE_VALUE\",\"message\":\"The scope parameter must be FULL, CNA_ONLY, or ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -5873,6 +6514,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_CVSSV2_SEVERITY\",\"message\":\"CRITICAL is not an allowed input for the cvssV2Severity. Please use LOW, MEDIUM, or HIGH.\"};", "pm.test('expected error code is returned', () => {", @@ -5917,6 +6559,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_CVSSV3_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", @@ -5961,6 +6604,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_CVSSV3_0_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_0Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", @@ -6005,6 +6649,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_CVSSV3_1_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV3_1Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", @@ -6049,6 +6694,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_CVSSV4_SEVERITY\",\"message\":\"UNKNOWN is not an allowed input for the cvssV4Severity. Please use NONE, LOW, MEDIUM, HIGH, or CRITICAL.\"};", "pm.test('expected error code is returned', () => {", @@ -6093,6 +6739,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcExploitation parameter must be one of: NONE, POC, ACTIVE.\"};", "pm.test('expected error code is returned', () => {", @@ -6137,6 +6784,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcAutomatable parameter must be one of: YES, NO.\"};", "pm.test('expected error code is returned', () => {", @@ -6181,6 +6829,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_ENUM_VALUE\",\"message\":\"The ssvcTechnicalImpact parameter must be one of: PARTIAL, TOTAL.\"};", "pm.test('expected error code is returned', () => {", @@ -6231,6 +6880,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"MISSING_KEYWORD_SEARCH_PARAM\",\"message\":\"The keywordSearch parameter is required when the keywordExactMatch parameter is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -6275,6 +6925,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"MISSING_CPE_NAME_PARAM\",\"message\":\"The cpeName parameter is required when the isVulnerable parameter is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -6319,6 +6970,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VIRTUAL_MATCH_AND_IS_VULN\",\"message\":\"The virtualMatchString parameter is not allowed when the isVulnerable parameter is provided. Please use cpeName instead.\"};", "pm.test('expected error code is returned', () => {", @@ -6363,6 +7015,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -6407,6 +7060,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"KEV_DATE_REQUIRES_KNOWN_EXPLOITED\",\"message\":\"knownExploited must be true when kevDateAddedStartDate or kevDateAddedEndDate is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -6457,6 +7111,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The cpeName parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -6501,6 +7156,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The isVulnerable parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -6545,6 +7201,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The virtualMatchString parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -6589,6 +7246,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStart parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -6633,6 +7291,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionStartType parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -6677,6 +7336,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEnd parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -6721,6 +7381,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SCOPE_FILTER_INCOMPATIBLE\",\"message\":\"The versionEndType parameter is not supported with scope ADP_ONLY.\"};", "pm.test('expected error code is returned', () => {", @@ -6765,6 +7426,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_CPE_NAME\",\"message\":\"The cpeName parameter must be a CPE 2.3 name with non-wildcard part, vendor, product, and version components.\"};", "pm.test('expected error code is returned', () => {", @@ -6809,6 +7471,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.\"};", "pm.test('expected error code is returned', () => {", @@ -6853,6 +7516,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must contain between 1 and 11 CPE components after cpe:2.3.\"};", "pm.test('expected error code is returned', () => {", @@ -6897,6 +7561,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString part component must be one of a, h, o, *, or -.\"};", "pm.test('expected error code is returned', () => {", @@ -6941,6 +7606,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"INVALID_CPE_NAME\",\"message\":\"The cpeName parameter cannot contain empty CPE components. Use * for a wildcard component or - for a not-applicable component.\"};", "pm.test('expected error code is returned', () => {", @@ -6985,6 +7651,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VERSION_RANGE_VIRTUAL_MATCH_STRING_REQUIRED\",\"message\":\"The virtualMatchString parameter is required when versionStart, versionStartType, versionEnd, or versionEndType is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -7029,6 +7696,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VERSION_START_PAIR_REQUIRED\",\"message\":\"The versionStart and versionStartType parameters must be provided together.\"};", "pm.test('expected error code is returned', () => {", @@ -7073,6 +7741,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VERSION_END_PAIR_REQUIRED\",\"message\":\"The versionEnd and versionEndType parameters must be provided together.\"};", "pm.test('expected error code is returned', () => {", @@ -7117,6 +7786,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VERSION_RANGE_TYPE\",\"message\":\"The versionStartType parameter must be including or excluding.\"};", "pm.test('expected error code is returned', () => {", @@ -7161,6 +7831,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "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', () => {", @@ -7205,6 +7876,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_FORMAT\",\"message\":\"The virtualMatchString parameter must begin with cpe:2.3 and include a valid part component.\"};", "pm.test('expected error code is returned', () => {", @@ -7249,6 +7921,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VIRTUAL_MATCH_STRING_VERSION_COMPONENT\",\"message\":\"The virtualMatchString parameter cannot include a version component when versionStart or versionEnd is provided.\"};", "pm.test('expected error code is returned', () => {", @@ -7293,6 +7966,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", "pm.test('expected error code is returned', () => {", @@ -7337,6 +8011,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"VERSION_RANGE_ORDER\",\"message\":\"The versionStart parameter must be less than versionEnd. Equal values require both bounds to be including.\"};", "pm.test('expected error code is returned', () => {", @@ -7374,7 +8049,52 @@ "description": "Timestamp format, calendar validity, and range order.", "item": [ { - "name": "Partial timestamp", + "name": "Timestamp without timezone", + "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();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", + "const errors = Array.isArray(payload) ? payload : [payload];", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.\"};", + "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 \"pubStartDate\": \"2024-01-01T00:00:00\"\n}", + "options": { "raw": { "language": "json" } } + } + }, + "response": [] + }, + { + "name": "Timestamp beyond millisecond precision", "event": [ { "listen": "test", @@ -7387,8 +8107,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -7411,7 +8132,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"pubStartDate\": \"2024-01-01\"\n}", + "raw": "{\n \"pubStartDate\": \"2024-01-01T00:00:00.1234Z\"\n}", "options": { "raw": { "language": "json" } } } }, @@ -7431,8 +8152,9 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", - "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The lastModStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'\"};", + "const expectedError = {\"error\":\"DATE_TIME_FORMAT\",\"message\":\"The lastModStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.\"};", "pm.test('expected error code is returned', () => {", " pm.expect(errors.map((entry) => entry.error)).to.include(expectedError.error);", "});", @@ -7475,6 +8197,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The pubStartDate parameter must be before or equal to pubEndDate.\"};", "pm.test('expected error code is returned', () => {", @@ -7519,6 +8242,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The lastModStartDate parameter must be before or equal to lastModEndDate.\"};", "pm.test('expected error code is returned', () => {", @@ -7563,6 +8287,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The ssvcTimestampStartDate parameter must be before or equal to ssvcTimestampEndDate.\"};", "pm.test('expected error code is returned', () => {", @@ -7607,6 +8332,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"DATE_RANGE_ORDER\",\"message\":\"The kevDateAddedStartDate parameter must be before or equal to kevDateAddedEndDate.\"};", "pm.test('expected error code is returned', () => {", @@ -7671,6 +8397,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_NOT_READY\",\"message\":\"Search service is not ready. Please try again later.\"};", "pm.test('expected error code is returned', () => {", @@ -7725,6 +8452,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_UNAVAILABLE\",\"message\":\"Search service is temporarily unavailable. Please try again later.\"};", "pm.test('expected error code is returned', () => {", @@ -7784,6 +8512,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"SEARCH_TIMEOUT\",\"message\":\"Search request timed out. Please try again later.\"};", "pm.test('expected error code is returned', () => {", @@ -7843,6 +8572,7 @@ "});", "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", "const payload = pm.response.json();", + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", "const errors = Array.isArray(payload) ? payload : [payload];", "const expectedError = {\"error\":\"UPSTREAM_SEARCH_ERROR\",\"message\":\"Search backend returned an error. Please try again later.\"};", "pm.test('expected error code is returned', () => {", diff --git a/test/postman/buildCollection.js b/test/postman/buildCollection.js index 2a7e0ad..03103e5 100644 --- a/test/postman/buildCollection.js +++ b/test/postman/buildCollection.js @@ -121,6 +121,7 @@ function errorTests(expectedStatus, expectedError) { ...requestIdTests(), "pm.test('response is JSON', () => pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));", 'const payload = pm.response.json();', + "if (pm.request.url.getPath() === '/search') pm.test('search errors use a single object', () => pm.expect(payload).to.be.an('object').and.not.an('array'));", 'const errors = Array.isArray(payload) ? payload : [payload];', `const expectedError = ${JSON.stringify(expectedError)};`, "pm.test('expected error code is returned', () => {", @@ -301,6 +302,11 @@ const baselineAndPagination = folder( { cveIds: `${ids.primary},${ids.rejected}`, resultsPerPage: 2, pageNumber: 1 }, { exactTotal: 2, expectedIds: [ids.primary, ids.rejected] } ), + ...['knownExploited', 'disputed'].flatMap((field) => + ['true', 'false'].map((value) => + positive(`${field} accepts string ${value}`, { cveIds: ids.primary, [field]: value }) + ) + ), positive( 'Second valid page can be empty', { cveIds: ids.primary, resultsPerPage: 1, pageNumber: 2 }, @@ -509,6 +515,21 @@ const cvssCoverage = folder('04 - CVSS filters', 'Every supported CVSS severity ]); const recordDates = folder('05 - Record date filters', 'Publication and modification boundaries.', [ + positive( + 'Date-only publication boundaries', + { cveIds: ids.primary, pubStartDate: '2024-01-01', pubEndDate: '2024-12-31' }, + { exactTotal: 1 } + ), + positive( + 'Publication timestamp with explicit offset', + { cveIds: ids.primary, pubStartDate: '2024-01-01T01:00:00+01:00' }, + { exactTotal: 1 } + ), + positive( + 'Publication timestamp without fractional seconds', + { cveIds: ids.primary, pubStartDate: '2024-01-01T00:00:00Z' }, + { exactTotal: 1 } + ), positive( 'pubStartDate', { cveIds: ids.primary, pubStartDate: '2024-01-01T00:00:00.000Z' }, @@ -955,6 +976,13 @@ const limitFailures = folder( error.cveIdsLimitError() ), negative('resultsPerPage must be positive', { resultsPerPage: -1 }, error.resultsPerPageError()), + ...['abc', 1.5, 0, true, [1]].map((resultsPerPage) => + negative( + `Invalid page size ${JSON.stringify(resultsPerPage)}`, + { resultsPerPage }, + error.resultsPerPageError() + ) + ), negative( 'resultsPerPage maximum', { resultsPerPage: constants.MAX_RESULTS_LIMIT + 1 }, @@ -1018,8 +1046,8 @@ const booleanAndEnumFailures = folder( negative('isVulnerable boolean', { isVulnerable: 'yes' }, error.isVulnInputError()), negative('keywordExactMatch boolean', { keywordExactMatch: 'yes' }, error.exactMatchInputError()), negative('noRejected boolean', { noRejected: 'yes' }, error.noRejectedValueError()), - negative('knownExploited boolean', { knownExploited: 'true' }, error.knownExploitedInputError()), - negative('disputed boolean', { disputed: 'false' }, error.disputedInputError()), + negative('knownExploited boolean', { knownExploited: 'yes' }, error.knownExploitedInputError()), + negative('disputed boolean', { disputed: 'no' }, error.disputedInputError()), negative('scope enum', { scope: 'CNA_AND_ADP' }, error.scopeValueError()), negative('CVSS v2 severity enum', { cvssV2Severity: 'CRITICAL' }, error.cvssV2SeverityError('CRITICAL')), negative('CVSS v3 severity enum', { cvssV3Severity: 'UNKNOWN' }, error.cvssV3SeverityError('UNKNOWN')), @@ -1187,7 +1215,16 @@ const cpeAndVersionFailures = folder( ); const dateFailures = folder('16 - Expected failures - dates', 'Timestamp format, calendar validity, and range order.', [ - negative('Partial timestamp', { pubStartDate: '2024-01-01' }, error.dateTimeError('pubStartDate')), + negative( + 'Timestamp without timezone', + { pubStartDate: '2024-01-01T00:00:00' }, + error.dateTimeError('pubStartDate') + ), + negative( + 'Timestamp beyond millisecond precision', + { pubStartDate: '2024-01-01T00:00:00.1234Z' }, + error.dateTimeError('pubStartDate') + ), negative( 'Impossible calendar date', { lastModStartDate: '2024-02-30T00:00:00.000Z' }, diff --git a/utils/appConfig.test.js b/utils/appConfig.test.js index 4740419..40e7087 100644 --- a/utils/appConfig.test.js +++ b/utils/appConfig.test.js @@ -12,6 +12,7 @@ function readConfig(env = {}) { const provider = require(${JSON.stringify(path.join(__dirname, 'searchProvider'))}); const flags = require(${JSON.stringify(path.join(__dirname, 'featureFlags'))}); const logging = require(${JSON.stringify(path.join(__dirname, 'requestLogging'))}); + const clientCachePolicy = require(${JSON.stringify(path.join(__dirname, 'clientCachePolicy'))}); console.log(JSON.stringify({ endpoint: config.has('search.providerEndpoint') ? config.get('search.providerEndpoint') : null, index: config.has('search.index') ? config.get('search.index') : null, @@ -19,6 +20,7 @@ function readConfig(env = {}) { timeout: provider.getOpenSearchRequestTimeoutMs(), enabled: flags.isWebSearchEnabled(), sampleRate: logging.getSuccessSampleRate(), + clientCacheMaxAge: clientCachePolicy.getMaxAgeSeconds(), missing: config.has('missing') })); `; @@ -50,6 +52,7 @@ test('default settings load without a dotenv file or endpoint', () => { timeout: 20000, enabled: true, sampleRate: 0.1, + clientCacheMaxAge: 60, missing: false }); }); @@ -64,7 +67,8 @@ test('dotenv is loaded before deployment selection and custom environment mappin 'OpenSearchAllowUnknownSslCerts=FALSE', 'SearchOpenSearchRequestTimeoutMs=25000', 'WebSearchEnabled=false', - 'RequestLogSuccessSampleRate=0.25' + 'RequestLogSuccessSampleRate=0.25', + 'SearchClientCacheMaxAgeSeconds=120' ].join('\n') ); expect(readConfig()).toStrictEqual({ @@ -74,6 +78,7 @@ test('dotenv is loaded before deployment selection and custom environment mappin timeout: 25000, enabled: false, sampleRate: 0.25, + clientCacheMaxAge: 120, missing: false }); }); diff --git a/utils/clientCachePolicy.js b/utils/clientCachePolicy.js new file mode 100644 index 0000000..807846c --- /dev/null +++ b/utils/clientCachePolicy.js @@ -0,0 +1,25 @@ +const AppConfig = require('./appConfig'); + +const DEFAULT_MAX_AGE_SECONDS = 60; + +function getMaxAgeSeconds() { + if (!AppConfig.has('clientCache.maxAgeSeconds')) return DEFAULT_MAX_AGE_SECONDS; + + const value = AppConfig.get('clientCache.maxAgeSeconds'); + if (!['string', 'number'].includes(typeof value) || !/^\d+$/.test(String(value))) return 0; + + const seconds = Number(value); + return Number.isSafeInteger(seconds) ? seconds : 0; +} + +function noStore(req, res, next) { + res.set('Cache-Control', 'no-store'); + next(); +} + +function setSearchCacheControl(res) { + const seconds = getMaxAgeSeconds(); + res.set('Cache-Control', seconds > 0 ? `private, max-age=${seconds}` : 'no-store'); +} + +module.exports = { getMaxAgeSeconds, noStore, setSearchCacheControl }; diff --git a/utils/clientCachePolicy.test.js b/utils/clientCachePolicy.test.js new file mode 100644 index 0000000..0fa1215 --- /dev/null +++ b/utils/clientCachePolicy.test.js @@ -0,0 +1,52 @@ +const mockHas = jest.fn(); +const mockGet = jest.fn(); + +jest.mock('./appConfig', () => ({ has: mockHas, get: mockGet })); + +const clientCachePolicy = require('./clientCachePolicy'); + +beforeEach(() => { + mockHas.mockReturnValue(false); + mockGet.mockReset(); +}); + +test('defaults to a 60-second private client-cache policy', () => { + const res = { set: jest.fn() }; + expect(clientCachePolicy.getMaxAgeSeconds()).toBe(60); + clientCachePolicy.setSearchCacheControl(res); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, max-age=60'); +}); + +test.each([ + ['120', 120], + [30, 30], + ['0', 0], + [0, 0] +])('accepts configured seconds %p', (value, seconds) => { + mockHas.mockReturnValue(true); + mockGet.mockReturnValue(value); + const res = { set: jest.fn() }; + expect(clientCachePolicy.getMaxAgeSeconds()).toBe(seconds); + clientCachePolicy.setSearchCacheControl(res); + expect(res.set).toHaveBeenCalledWith('Cache-Control', seconds > 0 ? `private, max-age=${seconds}` : 'no-store'); +}); + +test.each( + ['', ' ', 'invalid', '-1', '1.5', 'Infinity', '9007199254740992', -1, Infinity, null, true, {}, []].map((value) => [ + value + ]) +)('invalid configuration disables caching rather than emitting an invalid header: %p', (value) => { + mockHas.mockReturnValue(true); + mockGet.mockReturnValue(value); + const res = { set: jest.fn() }; + clientCachePolicy.setSearchCacheControl(res); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store'); +}); + +test('sets no-store before request processing continues', () => { + const res = { set: jest.fn() }; + const next = jest.fn(); + clientCachePolicy.noStore({}, res, next); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(next).toHaveBeenCalledTimes(1); +}); diff --git a/utils/errors.js b/utils/errors.js index 6710d26..5a28351 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -329,7 +329,7 @@ class errors { dateTimeError(dateTime) { return makeError( 'DATE_TIME_FORMAT', - `The ${dateTime} parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'` + `The ${dateTime} parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.` ); } diff --git a/utils/errors.test.js b/utils/errors.test.js index 9e1b0c2..23ebe8d 100644 --- a/utils/errors.test.js +++ b/utils/errors.test.js @@ -312,7 +312,8 @@ describe('error response helpers', () => { ['pubStartDate'], { error: 'DATE_TIME_FORMAT', - message: "The pubStartDate parameter must match the following format: yyyy-MM-ddTHH:mm:ss.SSS'Z'" + message: + 'The pubStartDate parameter must be a valid calendar date (YYYY-MM-DD) or a timestamp with seconds, an explicit Z or +/-HH:MM timezone, and at most 3 fractional-second digits.' } ], [ diff --git a/utils/searchDates.js b/utils/searchDates.js new file mode 100644 index 0000000..b90fb13 --- /dev/null +++ b/utils/searchDates.js @@ -0,0 +1,33 @@ +const DATE_RANGE_PAIRS = Object.freeze([ + ['lastModStartDate', 'lastModEndDate'], + ['pubStartDate', 'pubEndDate'], + ['ssvcTimestampStartDate', 'ssvcTimestampEndDate'], + ['kevDateAddedStartDate', 'kevDateAddedEndDate'] +]); +const DATE_PARAMS = Object.freeze(DATE_RANGE_PAIRS.flat()); +const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/; +const timestampPattern = + /^(\d{4}-\d{2}-\d{2})[Tt]([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?([Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; + +function normalizeDateTime(value, { endOfDay = false } = {}) { + if (typeof value !== 'string') return null; + if (dateOnlyPattern.test(value)) { + const canonical = `${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}Z`; + const date = new Date(canonical); + return Number.isFinite(date.getTime()) && date.toISOString() === canonical ? canonical : null; + } + + const match = timestampPattern.exec(value); + if (!match) return null; + const [, day, hour, minute, second, fraction = '', zone] = match; + const time = `${day}T${hour}:${minute}:${second}.${fraction.padEnd(3, '0')}`; + // Validate the original local calendar date before an offset can normalize it. + const local = new Date(`${time}Z`); + if (!Number.isFinite(local.getTime()) || local.toISOString() !== `${time}Z`) return null; + const date = new Date(`${time}${zone.toUpperCase()}`); + if (!Number.isFinite(date.getTime())) return null; + const canonical = date.toISOString(); + return /^\d{4}-/.test(canonical) ? canonical : null; +} + +module.exports = { DATE_PARAMS, DATE_RANGE_PAIRS, normalizeDateTime }; diff --git a/utils/searchDates.test.js b/utils/searchDates.test.js new file mode 100644 index 0000000..fd066b5 --- /dev/null +++ b/utils/searchDates.test.js @@ -0,0 +1,55 @@ +const { normalizeDateTime } = require('./searchDates'); + +describe('search date normalization', () => { + test.each([ + ['2024-02-29', '2024-02-29T00:00:00.000Z'], + ['2024-01-01T00:00:00Z', '2024-01-01T00:00:00.000Z'], + ['2024-01-01t00:00:00z', '2024-01-01T00:00:00.000Z'], + ['2024-01-01T00:00:00.1Z', '2024-01-01T00:00:00.100Z'], + ['2024-01-01T00:00:00.12Z', '2024-01-01T00:00:00.120Z'], + ['2024-01-01T00:00:00.123Z', '2024-01-01T00:00:00.123Z'], + ['2024-01-01T00:00:00+00:00', '2024-01-01T00:00:00.000Z'], + ['2024-01-01T00:00:00+05:30', '2023-12-31T18:30:00.000Z'], + ['2024-12-31T23:00:00-02:00', '2025-01-01T01:00:00.000Z'], + ['0000-01-01', '0000-01-01T00:00:00.000Z'] + ])('normalizes %s to UTC', (input, expected) => { + expect(normalizeDateTime(input)).toBe(expected); + }); + + test('only date-only end boundaries expand to the end of the day', () => { + expect(normalizeDateTime('2024-02-29', { endOfDay: true })).toBe('2024-02-29T23:59:59.999Z'); + expect(normalizeDateTime('2024-02-29T00:00:00Z', { endOfDay: true })).toBe('2024-02-29T00:00:00.000Z'); + }); + + test.each( + [ + undefined, + null, + 2024, + [], + {}, + '', + '2024', + '2024-01', + '2023-02-29', + '2024-02-30', + '2024-13-01', + '2024-01-00', + '2024-01-01T00:00Z', + '2024-01-01T00:00:00', + '2024-02-30T00:00:00+05:30', + '2024-01-01T24:00:00Z', + '2024-01-01T00:60:00Z', + '2024-01-01T00:00:60Z', + '2024-01-01T00:00:00.1234Z', + '2024-01-01T00:00:00+24:00', + '2024-01-01T00:00:00+00:60', + '9999-12-31T23:59:59-01:00', + '0000-01-01T00:00:00+01:00', + ' 2024-01-01 ', + '2024-01-01 00:00:00Z' + ].map((value) => [value]) + )('rejects unsupported or impossible input %j', (value) => { + expect(normalizeDateTime(value)).toBeNull(); + }); +});