diff --git a/config/devel.jsonc b/config/devel.jsonc index c2d14ba..2f9e340 100644 --- a/config/devel.jsonc +++ b/config/devel.jsonc @@ -31,8 +31,8 @@ "fixtures": { // @todo these constants needs to be in sync in cve-fixtures // so that testing snapshots are consistent and valid - "name": "fixtures-search-baseline-1086", // release tag - "numCves": "1086" // possible identifier assuming we always add cves to a new release + "name": "fixtures-search-baseline-1008", // release tag + "numCves": "1008" // possible identifier assuming we always add cves to a new release } }, // constants for testing node-config diff --git a/package-lock.json b/package-lock.json index 628b376..a0ba7ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7833,4 +7833,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/adapters/search/OpensearchDatetimeUtils.test.unit.ts b/src/adapters/search/OpensearchDatetimeUtils.test.unit.ts new file mode 100644 index 0000000..f6a6b01 --- /dev/null +++ b/src/adapters/search/OpensearchDatetimeUtils.test.unit.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from '@jest/globals'; +import { toSearchDateDslString } from './OpensearchDatetimeUtils'; +import { IsoDate, IsoDatetime } from '../../common/IsoDate/IsoDatetime'; + + +describe('OpensearchDatetimeUtils.toSearchDateDslString()', () => { + const testcases = [ + { input: '2025-03-01T12:34:56Z', expected: '2025-03-01T12:34:56Z' }, + { input: '2025-03-01', expected: '2025-03-01||/d' }, + { input: '2025-03', expected: '2025-03||/M' }, + { input: '2025', expected: '2025||/y' }, + ]; + + testcases.forEach(({ input, expected }) => { + test(`throws for "${input}"`, () => { + let iso = (input.length > 10) ? IsoDatetime.parse(input) : IsoDate.parse(input); + expect(toSearchDateDslString(iso)).toBe(expected); + }); + }); + + test('throws on invalid date string', () => { + expect(() => IsoDate.parse('2025-13')).toThrow(); + }); +}); \ No newline at end of file diff --git a/src/adapters/search/OpensearchDatetimeUtils.ts b/src/adapters/search/OpensearchDatetimeUtils.ts new file mode 100644 index 0000000..16a3bbd --- /dev/null +++ b/src/adapters/search/OpensearchDatetimeUtils.ts @@ -0,0 +1,25 @@ +/** + * OpenSearch and ElasticSearch both use additional symbols in the Search DSL to make ISO 8601 representations + * even more precise for years, months, days. This utility class simplifies that + */ + +import { IsoDate, IsoDatetime } from '../../common/IsoDate/IsoDatetime.js'; + +export const toSearchDateDslString = (iso: IsoDate | IsoDatetime): string => { + if (iso.isMonth()) { + // isoDate is a month + return `${iso.toString()}||/M`; + } + else if (iso.isYear()) { + // isoDate is a year + return `${iso.toString()}||/y`; + } + else if (iso.isDate()) { + // isoDate is a year + return `${iso.toString()}||/d`; + } + else { + // all other instances will be the original string + return iso.toString(); + } +}; \ No newline at end of file diff --git a/src/common/IsoDate/IsoDate.test.unit.ts b/src/common/IsoDate/IsoDate.test.unit.ts new file mode 100644 index 0000000..dce7657 --- /dev/null +++ b/src/common/IsoDate/IsoDate.test.unit.ts @@ -0,0 +1,121 @@ +/** + * Unit tests for IsoDate + * + * AI Usage: + * - This code was originally generated using + * 1. OpenAI/GPT OSS 120b on Roo Code + * 2. Gemini 2.5 Flash and 2.5 Pro + * then modified to fix incorrect implementations and fit project needs. + * The first commit contains these corrections so that all code committed + * works as designed. + */ + +import { describe, test, expect } from '@jest/globals'; +import { IsoDate, isValidIsoDate } from './IsoDate'; + +describe('IsoDate.parse – valid inputs', () => { + + test('full date with hyphens', () => { + const str = '2025-01-01'; + expect(isValidIsoDate(str)).toBeTruthy(); + const d = IsoDate.parse(str); + expect(d.year).toBe(2025); + expect(d.month).toBe(1); + expect(d.day).toBe(1); + expect(d.toString()).toBe('2025-01-01'); + expect(d.isDate()).toBeTruthy(); + expect(d.isMonth()).toBeFalsy(); + expect(d.isYear()).toBeFalsy() + }); + + test('year‑month', () => { + const str = '2025-01'; + expect(isValidIsoDate(str)).toBeTruthy(); + const d = IsoDate.parse(str); + expect(d.year).toBe(2025); + expect(d.month).toBe(1); + expect(d.day).toBeUndefined(); + expect(d.toString()).toBe('2025-01'); + expect(d.isDate()).toBeFalsy(); + expect(d.isMonth()).toBeTruthy(); + expect(d.isYear()).toBeFalsy() + }); + + test('year only', () => { + const str = '2025'; + expect(isValidIsoDate(str)).toBeTruthy(); + const d = IsoDate.parse(str); + expect(d.year).toBe(2025); + expect(d.month).toBeUndefined(); + expect(d.day).toBeUndefined(); + expect(d.toString()).toBe('2025'); + expect(d.isDate()).toBeFalsy(); + expect(d.isMonth()).toBeFalsy(); + expect(d.isYear()).toBeTruthy() + }); + + test('leap‑year February 29', () => { + const str = '2024-02-29'; + expect(isValidIsoDate(str)).toBeTruthy(); + const d = IsoDate.parse(str); + expect(d.year).toBe(2024); + expect(d.month).toBe(2); + expect(d.day).toBe(29); + expect(d.toString()).toBe('2024-02-29'); + }); + + test('month‑day boundary', () => { + const str = '2025-01-30'; + expect(isValidIsoDate(str)).toBeTruthy(); + const d = IsoDate.parse(str); + expect(d.year).toBe(2025); + expect(d.month).toBe(1); + expect(d.day).toBe(30); + expect(d.toString()).toBe('2025-01-30'); + }); +}); + +describe('IsoDate.parse – invalid inputs', () => { + const invalid = [ + '202501', // year+month without hyphen + '20250101', // compact full date (properly rejected in this class) + '250101', // two‑digit year + '2025-13-01', // invalid month + '2025-02-30', // invalid day (Feb 30) + '2025-04-31', // invalid day (April 31) + '-2025-04-31', // invalid year + '--01-01', // leading hyphens + '-2025-01', // leading hyphen before year + '2025--01', // double hyphen between year and month + '2025-01--01', // double hyphen before day + '2025-02-29', // illegal leap year + '2025-01-01T014:00:00:00Z', // datetime does not match in this class + ]; + + invalid.forEach((value) => { + test(`throws for "${value}"`, () => { + expect(() => IsoDate.parse(value)).toThrow(Error); + }); + }); + + invalid.forEach((value) => { + test(`"${value}" is not an IsoDate`, () => { + expect(isValidIsoDate(value)).toBeFalsy(); + }); + }); +}); + +describe('IsoDate.toString', () => { + const tests: Array<{ input: string; expected: string; }> = [ + { input: '2025-01-01', expected: '2025-01-01' }, + { input: '2025-01', expected: '2025-01' }, + { input: '2025', expected: '2025' } + ]; + + tests.forEach(({input, expected}) => { + test(`properly prints out '${input}' as '${expected}'`, () => { + const isoDate = IsoDate.parse(input) + expect(isoDate.toString()).toBe(expected) + }); + }); +}) \ No newline at end of file diff --git a/src/common/IsoDate/IsoDate.ts b/src/common/IsoDate/IsoDate.ts new file mode 100644 index 0000000..496d093 --- /dev/null +++ b/src/common/IsoDate/IsoDate.ts @@ -0,0 +1,176 @@ +/** + * IsoDate – a lightweight class for representing calendar dates. + * + * Supported input formats: + * - YYYY-MM-DD (e.g., 2025-01-01, equivalent to 2025-01-01T00:00:00.000Z/2025-01-01T23:59:59.999Z) + * - YYYY-MM (e.g., 2025-01, equivalent to 2025-01-01T00:00:00.000Z/2025-01-31T23:59:59.999Z) + * - YYYY (e.g., 2025, equivalent to 2025-01-01T00:00:00.000Z/2025-12-31T23:59:59.999Z) + * + * As shown in the example above, all IsoDate is assumed to be UTC (i.e., timezone = 'Z') + * + * Note we do not support (even though it is allowed by ISO 8601) + * - "compact date" (i.e., YYYYMMDD) + * - years previous to 1 AD (i.e., zero and negative years) + * - years after 2500 + * + * The class validates monthly day rules and leap‑year rules. + * It provides a normalized `toString()` output: + * - full date → YYYY-MM-DD + * - year‑month → YYYY-MM + * - year only → YYYY + * + * Example: + * const d = IsoDate.parse('2025-01-01'); + * console.log(d.year, d.month, d.day); // 2025 1 1 + * console.log(d.toString()); // "2025-01-01" + * + * AI Usage: + * - This code was originally generated using + * 1. OpenAI/GPT OSS 120b on Roo Code + * 2. Gemini 2.5 Flash and 2.5 Pro + * then modified to fix incorrect implementations and fit project needs. + * The first commit contains these corrections so that all code committed + * works as designed. + */ + +export class IsoDate { + + /** Full year (e.g., 2025) */ + public readonly year: number; + /** Month number 1‑12 (optional) */ + public readonly month?: number; + /** Day number 1‑31 (optional, requires month) */ + public readonly day?: number; + + protected constructor(year: number, month?: number, day?: number) { + this.year = year; + if (month !== undefined) this.month = month; + if (day !== undefined) this.day = day; + } + + /** + * Parse a string into a IsoDate. + * Throws an Error if the string does not match any supported format + * or if the date components are out of range. + */ + public static parse(value: string): IsoDate { + // Regex with named capture groups for clarity. + // 1. YYYY‑MM‑DD + // 2. we do not allow YYYYMMDD anymore + // 3. YYYY‑MM + // 4. YYYY + const regex = + // GPT OSS 120b generated regex + // /^(?\d{4})(?:[-]?(?\d{2})(?:[-]?(?\d{2})?)?)?$/; + /^(?\d{4})(?:[-](?\d{2})(?:[-](?\d{2})?)?)?$/; + + const match = regex.exec(value); + if (!match || !match.groups) { + throw new Error(`Invalid calendar date format: "${value}": must be one of YYYY-MM-DD, YYYY-MM, or YYYY`); + } + + const year = Number(match.groups.year); + const monthStr = match.groups.month; + const dayStr = match.groups.day; + + // Validate year range (reasonable limits) + if (year < 1 || year > 2500) { + throw new Error(`Year out of range: ${year}`); + } + + // If month is present, validate it. + if (monthStr !== undefined) { + const month = Number(monthStr); + if (month < 1 || month > 12) { + throw new Error(`Month out of range: ${monthStr}`); + } + + // If day is present, validate day according to month & leap year. + if (dayStr !== undefined) { + const day = Number(dayStr); + const maxDay = IsoDate.daysInMonth(year, month); + if (day < 1 || day > maxDay) { + throw new Error( + `Day out of range for ${year}-${String(month).padStart( + 2, + '0' + )}: ${dayStr}` + ); + } + return new IsoDate(year, month, day); + } + + // Month only (no day) + return new IsoDate(year, month); + } + + // Year only + return new IsoDate(year); + } + + /** Return true if the stored year is a leap year. */ + public isLeapYear(): boolean { + return IsoDate.isLeapYear(this.year); + } + + /** Return true if the stored year is a leap year. */ + public isYear(): boolean { + return this.toString().length === 4; + } + + /** Return true if the stored year is a leap year. */ + public isMonth(): boolean { + return this.toString().length === 7; + } + + /** Return true if the stored year is a leap year. */ + public isDate(): boolean { + return this.toString().length === 10; + } + + + /** Normalized string representation. */ + public toString(): string { + const y = String(this.year).padStart(4, '0'); + if (this.month !== undefined) { + const m = String(this.month).padStart(2, '0'); + if (this.day !== undefined) { + const d = String(this.day).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + return `${y}-${m}`; + } + return y; + } + + /** Static function that returns true iff year is a leap‑year */ + public static isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + } + + /** Static function that returns number of days in a given month/year. */ + public static daysInMonth(year: number, month: number): number { + switch (month) { + case 2: + return IsoDate.isLeapYear(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } +} + +/* Utility function to check if a string is a valid ISO date according to IsoDate parsing rules + */ +export function isValidIsoDate(value: string): boolean { + try { + IsoDate.parse(value); + return true; + } catch { + return false; + } +} diff --git a/src/common/IsoDate/IsoDateString.test.ts b/src/common/IsoDate/IsoDateString.test.ts index 41f8871..79e06dd 100644 --- a/src/common/IsoDate/IsoDateString.test.ts +++ b/src/common/IsoDate/IsoDateString.test.ts @@ -1,3 +1,4 @@ +import { describe, it, expect } from '@jest/globals'; import { IsoDateStringRegEx, IsoDateString } from './IsoDateString.js'; describe(`IsoDateString`, () => { diff --git a/src/common/IsoDate/IsoDateString.ts b/src/common/IsoDate/IsoDateString.ts index e566918..9dd2045 100644 --- a/src/common/IsoDate/IsoDateString.ts +++ b/src/common/IsoDate/IsoDateString.ts @@ -5,6 +5,8 @@ * * Note that in the future, if necessary, we can extend what this class covers, but for now * this strict and opinionated set is very useful for processing ISO Date+Time+TZ strings + * + * @deprecated Use IsoDatetime or IsoDate instead for safer and more efficient datetime and date functions */ /** a regular expression to represent an ISO Date+Time+TZ string diff --git a/src/common/IsoDate/IsoDatetime.test.int.ts b/src/common/IsoDate/IsoDatetime.test.int.ts new file mode 100644 index 0000000..40c4a15 --- /dev/null +++ b/src/common/IsoDate/IsoDatetime.test.int.ts @@ -0,0 +1,29 @@ +import { describe, test, expect } from '@jest/globals'; +import { IsoDate } from './IsoDate.js'; +import { IsoDatetime, toIsoDatetime } from './IsoDatetime.js'; + +describe('IsoDatetime.fromIsoDate', () => { + test('converts an IsoDate to midnight UTC IsoDatetime', () => { + const date = IsoDate.parse('2025-03-15'); + const datetime = IsoDatetime.fromIsoDate(date); + expect(datetime).toBeInstanceOf(IsoDatetime); + expect(datetime.toString()).toBe('2025-03-15T00:00:00Z'); + }); +}); + + +describe('IsoDatetime.toIsoDatetime', () => { + test('converts an IsoDate to midnight UTC IsoDatetime', () => { + const date = IsoDate.parse('2025-03-15'); + const datetime = toIsoDatetime(date); + expect(datetime).toBeInstanceOf(IsoDatetime); + expect(datetime.toString()).toBe('2025-03-15T00:00:00Z'); + }); + + test('converts an IsoDatetime to midnight UTC IsoDatetime', () => { + const date = IsoDatetime.parse('2025-03-15T00:00:00Z'); + const datetime = toIsoDatetime(date); + expect(datetime).toBeInstanceOf(IsoDatetime); + expect(datetime.toString()).toBe('2025-03-15T00:00:00Z'); + }); +}); \ No newline at end of file diff --git a/src/common/IsoDate/IsoDatetime.test.unit.ts b/src/common/IsoDate/IsoDatetime.test.unit.ts new file mode 100644 index 0000000..fedf9ad --- /dev/null +++ b/src/common/IsoDate/IsoDatetime.test.unit.ts @@ -0,0 +1,213 @@ +/** + * Unit tests for IsoDatetime + * + * AI Usage: + * - This code was originally generated using + * 1. OpenAI/GPT OSS 120b on Roo Code + * 2. Gemini 2.5 Flash and 2.5 Pro + * then modified to fix incorrect implementations and fit project needs. + * The first commit contains these corrections so that all code committed + * works as designed. + */ +import { describe, test, expect } from '@jest/globals'; +import { IsoDate } from './IsoDatetime.js'; +import { IsoDatetime } from './IsoDatetime.js'; + +describe('IsoDatetime.parse – valid inputs', () => { + test('basic UTC datetime without fractional seconds', () => { + const str = '2025-03-01T12:34:56Z'; + const dt = IsoDatetime.parse(str); + expect(dt.year).toBe(2025); + expect(dt.month).toBe(3); + expect(dt.day).toBe(1); + expect(dt.hour).toBe(12); + expect(dt.minute).toBe(34); + expect(dt.second).toBe(56); + expect(dt.millisecond).toBe(0); + expect(dt.toString()).toBe('2025-03-01T12:34:56Z'); + }); + + test('datetime with fractional seconds', () => { + const str = '2025-03-01T12:34:56.789Z'; + const dt = IsoDatetime.parse(str); + expect(dt.millisecond).toBe(789); + expect(dt.toString()).toBe('2025-03-01T12:34:56.789Z'); + }); + + test('datetime with positive offset', () => { + const str = '2025-03-01T12:34:56+02:00'; + const dt = IsoDatetime.parse(str); + // 12:34:56+02:00 => 10:34:56Z + expect(dt.toString()).toBe('2025-03-01T10:34:56Z'); + }); + + test('datetime with negative offset', () => { + const str = '2025-03-01T12:34:56-04:30'; + const dt = IsoDatetime.parse(str); + // 12:34:56-04:30 => 17:04:56Z + expect(dt.toString()).toBe('2025-03-01T17:04:56Z'); + }); + + test('datetime crossing day boundary with positive offset', () => { + const str = '2025-03-01T01:15:00+05:00'; + const dt = IsoDatetime.parse(str); + // 01:15 +05:00 => previous day 20:15Z + expect(dt.toString()).toBe('2025-02-28T20:15:00Z'); + }); + + test('datetime crossing month boundary with negative offset', () => { + const str = '2025-03-01T00:30:00-02:00'; + const dt = IsoDatetime.parse(str); + // 00:30 -02:00 => next day 02:30Z + expect(dt.toString()).toBe('2025-03-01T02:30:00Z'); + }); + + const valid = [ + { input: '2024-02-29T23:00:00+01:00', expected: '2024-02-29T22:00:00Z' }, // leap year date with offset + { input: '2025-03-01', expected: '2025-03-01T00:00:00Z' }, // date only + { input: '2025-03-01T12:34:56.789', expected: '2025-03-01T12:34:56.789Z' }, // missing Z + // currently does not allow the following even though it is valid in ISO 8601 + // { input: '2025-03', expected: '2024-03' }, // month only + // { input: '2025', expected: '2024' }, // year only + ]; + + valid.forEach(({ input, expected }) => { + test(`IsoDatetime.parse(${input})`, () => { + const datetime = IsoDatetime.parse(input); + expect(datetime.toString()).toBe(expected); + }); + }); +}); + + +describe('IsoDatetime.parse – invalid inputs', () => { + const invalid = [ + '2025-03', // month only + '2025', // year only + '2025-03-01 12:34:56Z', // no 'T' separator + '2025-03-01T12:34Z', // missing seconds + '2025-13-01T12:34:56Z', // bad month + '2025-00-01T12:34:56Z', // bad month + '2025-02-30T12:34:56Z', // bad number of days in February + '2025-03-01T24:00:00Z', // bad minute + '2025-03-01T12:60:00Z', // bad minute + '2025-03-01T12:34:60Z', // bad second + // '2025-03-01T12:34:56.1000Z', + // '2025-03-01T12:34:56+25:00', + // '2025-03-01T12:34:56+02:60', + ]; + + invalid.forEach((value) => { + test(`throws for "${value}"`, () => { + expect(() => IsoDatetime.parse(value)).toThrow(Error); + }); + }); +}); + + +describe('IsoDatetime.fromIsoDate()', () => { + const valid = [ + { input: '2025-03-02', expected: '2025-03-02T00:00:00Z' }, // date only + { input: '2025-03', expected: '2025-03-01T00:00:00Z' }, // month only + { input: '2025', expected: '2025-01-01T00:00:00Z' }, // year only + ]; + + valid.forEach(({ input, expected }) => { + test(`IsoDatetime.fromIsoDate(${input})`, () => { + const date = IsoDate.parse(input); + const datetime = IsoDatetime.fromIsoDate(date); + expect(datetime.toString()).toBe(expected); + }); + }); +}); + + +describe('IsoDatetime.toString – formatting', () => { + const tests = [ + { input: '2025-03-01T12:34:56Z', expected: '2025-03-01T12:34:56Z' }, + { input: '2025-03-01T12:34:56.001Z', expected: '2025-03-01T12:34:56.001Z' }, + { input: '2025-03-01T12:34:56+02:00', expected: '2025-03-01T10:34:56Z' }, + { input: '2025-03-01T12:34:56-04:30', expected: '2025-03-01T17:04:56Z' }, + ]; + + tests.forEach(({ input, expected }) => { + test(`parses "${input}" and formats as "${expected}"`, () => { + const dt = IsoDatetime.parse(input); + expect(dt.toString()).toBe(expected); + }); + }); +}); + + +describe('IsoDatetime.toIsoDate', () => { + const tests = [ + { input: '2025-03-01T12:34:56Z', expected: '2025-03-01' }, + { input: '2025-03-01T12:34:56.001Z', expected: '2025-03-01' }, + { input: '2025-03-01T12:34:56+02:00', expected: '2025-03-01' }, + { input: '2025-03-01T12:34:56-04:30', expected: '2025-03-01' }, + ]; + + tests.forEach(({ input, expected }) => { + test(`converts an IsoDatetime(${input}) to its IsoDate equivalent`, () => { + const isoDatetime = IsoDatetime.parse(input); + const isoDate = isoDatetime.toIsoDate(); + expect(isoDate.toString()).toBe(expected); + }); + }); +}); + +describe('IsoDatetime.getNextDay – day increments and decrements', () => { + test('regular date: March 4 +1 day => March 5', () => { + const dt = IsoDatetime.parse('2024-03-04T00:00:00Z'); + expect(dt.getNextDay()).toEqual(IsoDatetime.parse('2024-03-05T00:00:00Z')); + }); + test('leap year: Feb 28 +1 day => Feb 29', () => { + const dt = IsoDatetime.parse('2024-02-28T00:00:00Z'); + expect(dt.getNextDay()).toEqual(IsoDatetime.parse('2024-02-29T00:00:00Z')); + }); + + test('non‑leap year: Feb 28 +1 day => Mar 01', () => { + const dt = IsoDatetime.parse('2025-02-28T00:00:00Z'); + expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2025-03-01T00:00:00Z')); + }); + + test('leap day: Feb 29 +1 day => Mar 01', () => { + const dt = IsoDatetime.parse('2024-02-29T00:00:00Z'); + expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2024-03-01T00:00:00Z')); + }); + + test('month boundary: Jan 31 +1 day => Feb 01', () => { + const dt = IsoDatetime.parse('2025-01-31T00:00:00Z'); + expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2025-02-01T00:00:00Z')); + }); + + test('year boundary: Dec 31 +1 day => Jan 01 of next year', () => { + const dt = IsoDatetime.parse('2025-12-31T00:00:00Z'); + expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2026-01-01T00:00:00Z')); + }); + + test('century non‑leap year (1900): Feb 28 +1 day => Mar 01', () => { + const dt = IsoDatetime.parse('1900-02-28T00:00:00Z'); + expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('1900-03-01T00:00:00Z')); + }); + + test('century leap year (2000): Feb 28 +1 day => Feb 29', () => { + const dt = IsoDatetime.parse('2000-02-28T00:00:00Z'); + expect(dt.getNextDay(1)).toEqual(IsoDatetime.parse('2000-02-29T00:00:00Z')); + }); + + test('negative increment: Mar 01 -1 day => Feb 28 (non‑leap year)', () => { + const dt = IsoDatetime.parse('2025-03-01T00:00:00Z'); + expect(dt.getNextDay(-1)).toEqual(IsoDatetime.parse('2025-02-28T00:00:00Z')); + }); + + test('negative increment: Mar 01 -1 day => Feb 28 (leap year)', () => { + const dt = IsoDatetime.parse('2024-03-01T00:00:00Z'); + expect(dt.getNextDay(-1)).toEqual(IsoDatetime.parse('2024-02-29T00:00:00Z')); + }); + + test('negative crossing year: Jan 01 -1 day => Dec 31 of previous year', () => { + const dt = IsoDatetime.parse('2025-01-01T00:00:00Z'); + expect(dt.getNextDay(-1)).toEqual(IsoDatetime.parse('2024-12-31T00:00:00Z')); + }); +}); diff --git a/src/common/IsoDate/IsoDatetime.ts b/src/common/IsoDate/IsoDatetime.ts new file mode 100644 index 0000000..413c144 --- /dev/null +++ b/src/common/IsoDate/IsoDatetime.ts @@ -0,0 +1,238 @@ +/** + * IsoDatetime – extends IsoDate to include time components and full ISO‑8601 datetime parsing. + * + * Supported input format (required ‘T’ separator): + * YYYY‑MM‑DD'T'HH:MM:SS[.sss][Z|±hh:mm] + * + * • Fractional seconds (.sss) are optional. + * • ‘Z’ denotes UTC. + * • A signed offset (e.g., +02:00 or -04:30) is interpreted and the resulting + * datetime is normalized to UTC. + * + * The parser validates component ranges and leap‑year rules, then stores the + * normalized UTC components (year, month, day, hour, minute, second, millisecond). + * + * Example: + * const dt = IsoDatetime.parse('2025-03-01T12:34:56.789+02:00'); + * console.log(dt.toString()); // "2025-03-01T10:34:56.789Z" + * + * AI Usage: + * - This code was originally generated using + * 1. OpenAI/GPT OSS 120b on Roo Code + * 2. Gemini 2.5 Flash and 2.5 Pro + * then modified to fix incorrect implementations and fit project needs. + * The first commit contains these corrections so that all code committed + * works as designed. + */ +import { IsoDate } from './IsoDate.js'; + +export * from './IsoDate.js' + +export class IsoDatetime extends IsoDate { + /** Hour (0‑23) */ + public readonly hour: number; + /** Minute (0‑59) */ + public readonly minute: number; + /** Second (0‑59) */ + public readonly second: number; + /** Millisecond (0‑999) – defaults to 0 when not provided */ + public readonly millisecond: number; + + private constructor( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + millisecond: number, + ) { + super(year, month, day); + this.hour = hour; + this.minute = minute; + this.second = second; + this.millisecond = millisecond; + } + + /** + * Parse a string into an IsoDatetime. + * Throws an Error if the string does not match the supported format + * or if any component is out of range. + */ + public static parse(value: string): IsoDatetime { + // Regex with named capture groups for clarity. + // 1. YYYY‑MM‑DD + // 2. T separator (required) + // 3. HH:MM:SS + // 4. optional .sss (fractional seconds) + // 5. timezone: Z or ±hh:mm + const regex = + /^(?\d{4})-(?\d{2})-(?\d{2})(?:T(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?\d+))?(?Z|[+-]\d{2}:\d{2})?)?$/; + + const match = regex.exec(value); + if (!match || !match.groups) { + throw new Error( + `Invalid ISO‑8601 datetime format: "${value}". Expected YYYY-MM-DDTHH:MM:SS[.sss][Z|±hh:mm]`, + ); + } + + const year = Number(match.groups.year); + const month = Number(match.groups.month); + const day = Number(match.groups.day); + const hour = Number(match.groups.hour ?? '0'); + const minute = Number(match.groups.minute ?? '0'); + const second = Number(match.groups.second ?? '0'); + const msStr = match.groups.ms ?? '0'; + const millisecond = Number(msStr.padEnd(3, '0').substring(0, 3)); // keep three digits + const tz = match.groups.tz ?? 'Z'; + + // Validate date components using IsoDate's helpers. + if (year < 1 || year > 2500) { + throw new Error(`Year out of range: ${year}`); + } + if (month < 1 || month > 12) { + throw new Error(`Month out of range: ${month}`); + } + const maxDay = IsoDate.daysInMonth(year, month); + if (day < 1 || day > maxDay) { + throw new Error(`Day out of range for ${year}-${String(month).padStart(2, '0')}: ${day}`); + } + + // Validate time components. + if (hour < 0 || hour > 23) { + throw new Error(`Hour out of range: ${hour}`); + } + if (minute < 0 || minute > 59) { + throw new Error(`Minute out of range: ${minute}`); + } + if (second < 0 || second > 59) { + throw new Error(`Second out of range: ${second}`); + } + if (millisecond < 0 || millisecond > 999) { + throw new Error(`Millisecond out of range: ${millisecond}`); + } + + // Compute UTC timestamp. + // Date.UTC creates a timestamp as if the supplied components are UTC. + let utcMs = Date.UTC(year, month - 1, day, hour, minute, second, millisecond); + + if (tz !== 'Z') { + // tz format: ±hh:mm + const sign = tz[0] === '+' ? -1 : 1; // offset must be subtracted to get UTC + const [offHour, offMin] = tz.slice(1).split(':').map(Number); + const offsetMinutes = sign * (offHour * 60 + offMin); + utcMs += offsetMinutes * 60 * 1000; + } + + const utcDate = new Date(utcMs); + const normYear = utcDate.getUTCFullYear(); + const normMonth = utcDate.getUTCMonth() + 1; + const normDay = utcDate.getUTCDate(); + const normHour = utcDate.getUTCHours(); + const normMinute = utcDate.getUTCMinutes(); + const normSecond = utcDate.getUTCSeconds(); + const normMs = utcDate.getUTCMilliseconds(); + + return new IsoDatetime( + normYear, + normMonth, + normDay, + normHour, + normMinute, + normSecond, + normMs, + ); + } + + /** Convert this IsoDate to an IsoDatetime + * if date -> at midnight UTC. + * if month -> 1st of month at midnight UTC. + * if year -> 1st of the year at midnight UTC. + */ + public static fromIsoDate(date: IsoDate): IsoDatetime { + let isoString; + if (date.isDate()) { + isoString = `${date.toString()}T00:00:00.000Z`; + } + else if (date.isMonth()) { + isoString = `${date.toString()}-01T00:00:00.000Z`; + } + else if (date.isYear()) { + isoString = `${date.toString()}-01-01T00:00:00.000Z`; + } + return IsoDatetime.parse(isoString); + } + + /** Normalized ISO‑8601 string in UTC (always ends with ‘Z’). */ + public toString(): string { + const y = String(this.year).padStart(4, '0'); + const m = String(this.month).padStart(2, '0'); + const d = String(this.day).padStart(2, '0'); + const h = String(this.hour).padStart(2, '0'); + const min = String(this.minute).padStart(2, '0'); + const s = String(this.second).padStart(2, '0'); + const msPart = this.millisecond > 0 ? `.${String(this.millisecond).padStart(3, '0')}` : ''; + return `${y}-${m}-${d}T${h}:${min}:${s}${msPart}Z`; + } + + /** returns an IsoDate + * Note: lossy precision + * Even though this reduces the precision of a Datetime, it is useful for some purposes + */ + public toIsoDate(): IsoDate { + const y = String(this.year).padStart(4, '0'); + const m = String(this.month).padStart(2, '0'); + const d = String(this.day).padStart(2, '0'); + const dateString = `${y}-${m}-${d}`; + return IsoDate.parse(dateString); + } + + + /** + * Returns an ISO‑8601 string representing this datetime shifted by the given + * number of days (positive or negative). The shift respects month lengths, + * leap‑year rules, and century leap‑year exceptions. + * + * @param increment Number of days to shift; may be negative. + */ + public getNextDay(increment: number = 1): IsoDatetime { + // Compute the UTC timestamp for the current instance. + const utcMs = Date.UTC( + this.year, + this.month - 1, + this.day, + this.hour, + this.minute, + this.second, + this.millisecond, + ); + // Add the day offset (24 h = 86 400 000 ms). + const newMs = utcMs + increment * 86_400_000; + const d = new Date(newMs); + const next = new IsoDatetime( + d.getUTCFullYear(), + d.getUTCMonth() + 1, + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds(), + d.getUTCMilliseconds(), + ); + return next; + } + +} + + +/* Utility function that always returns a IsoDatetime version + * of a IsoDate or IsoDatetime object, very useful + * when the type of the IsoDate object can be either + */ +export function toIsoDatetime(o: IsoDate | IsoDatetime): IsoDatetime { + if (o instanceof IsoDatetime) { + return o; + } + else { // if (o instanceof IsoDate) { + return IsoDatetime.fromIsoDate(o); + } +} diff --git a/src/date/CveDate.ts b/src/date/CveDate.ts index 8d658a0..3fd3ab9 100644 --- a/src/date/CveDate.ts +++ b/src/date/CveDate.ts @@ -1,8 +1,17 @@ +import { + differenceInSeconds, + // endOfYesterday, + // startOfToday, + // startOfYesterday, + sub +} from 'date-fns'; +import { formatInTimeZone } from 'date-fns-tz'; +import { IsoDateString } from '../common/IsoDate/IsoDateString.js'; + /** * Date utility and class to * - facilitate using dates in CveRecords and Javascript, standardizing all dates to * ISO format: 2023-03-29T00:00:00.000Z - * - provide timer functions inside instances * * This is necessary because the Javascript Date object, while tracking UTC time * internally (that is, the number of milliseconds since 1970-01-01T00:00:00.000Z) @@ -17,19 +26,10 @@ * Throughout this class, we will use * - jsDate to represent a standard JS Date object * - isoDateStr to represent an ISO/UTC/Z date string (e.g. 2023-03-29T00:00:00.000Z) + * + * @deprecated Use IsoDatetime or IsoDate instead for safer and more efficient datetime and date functions */ - -import { - differenceInSeconds, - // endOfYesterday, - // startOfToday, - // startOfYesterday, - sub -} from 'date-fns'; -import { formatInTimeZone } from 'date-fns-tz'; -import { IsoDateString } from '../common/IsoDate/IsoDateString.js'; - export class CveDate { /** the Date object this CveDate instance wraps */ diff --git a/src/search/BasicSearchManager.ts b/src/search/BasicSearchManager.ts index 94d06ed..fbb3251 100644 --- a/src/search/BasicSearchManager.ts +++ b/src/search/BasicSearchManager.ts @@ -54,7 +54,7 @@ export class BasicSearchManager { let response = undefined; const builder = new SearchQueryBuilder(searchText, options); const result: CveResult = builder.buildQuery() - // console.log(`result=${JSON.stringify(result, null, 2)}`) + // console.log(`query body (q)=${JSON.stringify(result.data['q'], null, 2)}`) if (result.isOk()) { // console.log(`q: ${JSON.stringify(result.data['q'], null, 2)}`) response = await this._searchReader._client.search({ diff --git a/src/search/SearchQueryBuilder.test.unit.ts b/src/search/SearchQueryBuilder.test.unit.ts index 6da0ecd..03f289e 100644 --- a/src/search/SearchQueryBuilder.test.unit.ts +++ b/src/search/SearchQueryBuilder.test.unit.ts @@ -1,5 +1,6 @@ // For a more comprehensive set of test cases, see the tests // in test_cases/search_* +import { describe, it, expect } from '@jest/globals'; import { SearchOptions } from "./BasicSearchManager.js" import { SearchRequestType, SearchRequest, SearchRequestTypeId } from "./SearchRequest.js"; @@ -110,6 +111,7 @@ describe(`SearchQueryBuilder`, () => { metadataOnly: true }], [`CAPEC-64`, { default_operator: 'OR' }], + [`2023-12-21`, { track_total_hits: true }], ] testCases.forEach((test: [string, Partial]) => { it(`(${test[0]},${JSON.stringify(test[1])})..buildQuery() correctly returns the expected query`, async () => { diff --git a/src/search/SearchQueryBuilder.ts b/src/search/SearchQueryBuilder.ts index a9549cc..87f98e9 100644 --- a/src/search/SearchQueryBuilder.ts +++ b/src/search/SearchQueryBuilder.ts @@ -1,7 +1,11 @@ import { CveResult } from '../result/CveResult.js'; +import { IsoDate } from '../common/IsoDate/IsoDate.js'; +import { IsoDatetime } from '../common/IsoDate/IsoDatetime.js'; +import { toSearchDateDslString } from '../adapters/search/OpensearchDatetimeUtils.js'; import { SearchOptions } from './BasicSearchManager.js'; import { SearchRequest } from './SearchRequest.js'; + /** * a search query builder that analyzes a user's search text and builds a proper search query * for OpenSearch @@ -9,13 +13,27 @@ import { SearchRequest } from './SearchRequest.js'; export class SearchQueryBuilder { /** default number of results to return when not specified */ - static kDefaultNumResults = 25 + static kDefaultNumResults = 25; + + /** the JSON paths to CVE fields that are of the date type */ + static kDateFieldPaths = [ + 'cveMetadata.datePublished', + 'cveMetadata.dateRejected', + 'cveMetadata.dateReserved', + 'cveMetadata.dateUpdated', + 'containers.cna.datePublic', + 'containers.cna.providerMetadata.dateUpdated', + 'containers.cna.timeline.time', + 'containers.adp.metrics.other.content.dateAdded', + 'containers.adp.metrics.other.content.timestamp', + 'containers.adp.providerMetadata.dateUpdated' + ]; /** the user entered text */ _searchText: string; /** search options when validating input and building query string */ - _searchOptions: SearchOptions + _searchOptions: SearchOptions; /** the searchRequest based on the search term(s) from the user */ _searchRequest: SearchRequest; @@ -44,9 +62,9 @@ export class SearchQueryBuilder { if (this._searchOptions.size < this._searchOptions.from) { this._searchOptions.size = this._searchOptions.from + 1; } - this._searchRequest = new SearchRequest(searchText) + this._searchRequest = new SearchRequest(searchText); } - + /** builds the proper query for openSearch */ buildQuery(): CveResult { @@ -61,25 +79,62 @@ export class SearchQueryBuilder { let q = { query: {} }; - // ----- query_string - q.query['query_string'] = { - query: `${this._searchText}`, - default_operator: this._searchOptions.default_operator - }; - // ----- _source, which specifies which CVE fields are to be returned - const source: string[] = []; - if (this._searchOptions.metadataOnly) { - source.push("cveMetadata", "containers.cna.descriptions.value"); - } - if (source.length > 0) { - q['_source'] = source; + // right now, we only handle 2 types of queries: + // 1. date/date ranges + // 2. query_string for everything else + const isDate = SearchRequest.isDateString(this._searchText); + // console.log(`isDate(): ${isDate}`) + if (isDate) { + // assemble all the date fields into an array + let dateFields = []; + // if the user had requested an IsoDate, it should stay as an IsoDate and not be cast to an IsoDatetime + // otherwise YYYY or YYYY-MM requests would not work + const startDate = (this._searchText.length > 10) ? IsoDatetime.parse(this._searchText) : IsoDate.parse(this._searchText); + const stopDate = IsoDatetime.fromIsoDate(startDate).getNextDay() + SearchQueryBuilder.kDateFieldPaths.map(path => { + let field = `{ + "range": { + "${path}": { + "gte": "${toSearchDateDslString(startDate)}", + "lt": "${toSearchDateDslString(stopDate)}" + } + } + }`; + dateFields.push(JSON.parse(field)); + }); + // console.log(`dateFields: ${JSON.stringify(dateFields, null, 2)}`); + q = { + query: { + bool: { + should: dateFields, + minimum_should_match: 1 + } + } + }; } - // ----- search only in fields - if (this._searchOptions.fields) { - source.push(...this._searchOptions.fields); + else { + q = { + query: { + query_string: { + query: this._searchText, + default_operator: this._searchOptions.default_operator + } + } + }; + // ----- _source, which specifies which CVE fields are to be returned + const source: string[] = []; + if (this._searchOptions.metadataOnly) { + source.push("cveMetadata", "containers.cna.descriptions.value"); + } + if (source.length > 0) { + q['_source'] = source; + } + // ----- search only in fields + if (this._searchOptions.fields) { + source.push(...this._searchOptions.fields); + } } - // console.log(`***${JSON.stringify(q, null, 2)}`) // ----- track_total_hits if (this._searchOptions.track_total_hits) { @@ -101,7 +156,7 @@ export class SearchQueryBuilder { q['size'] = this._searchOptions.size; // ----- q result.data['q'] = q; - + // console.log(`q: ${JSON.stringify(q, null, 2)}`) return result; } diff --git a/src/search/SearchRequest.test.unit.ts b/src/search/SearchRequest.test.unit.ts index 80859cc..f4d1fac 100644 --- a/src/search/SearchRequest.test.unit.ts +++ b/src/search/SearchRequest.test.unit.ts @@ -1,6 +1,6 @@ // For a more comprehensive set of test cases, see the tests // in test_cases/search_* - +import { describe, it, expect } from '@jest/globals'; import { SearchOptions } from "./BasicSearchManager.js" import { SearchRequestType, SearchRequest, SearchRequestTypeId } from "./SearchRequest.js"; diff --git a/src/search/SearchRequest.ts b/src/search/SearchRequest.ts index c78982c..4403a6b 100644 --- a/src/search/SearchRequest.ts +++ b/src/search/SearchRequest.ts @@ -4,6 +4,7 @@ import validator from 'validator'; // import { CveId } from "../CveId.js" import { CveResult, CveErrorId } from "../result/CveResult.js"; import { SearchOptions } from "./BasicSearchManager.js" +import { SearchToken } from './SearchToken.js'; export const SearchRequestType = { @@ -33,6 +34,10 @@ export const SearchRequestType = { 'SEARCH_AS_FILESPEC': `search text is a version string`, 'SEARCH_PHRASE': `search text is a phrase (surrounded by double quotes)`, + // dates and date ranges + 'SEARCH_DATE': `search text is a date (ISO 8601)`, + 'SEARCH_DATE_RANGE': `search text is a date range (ISO 8601)`, + // multiple types 'SEARCH_STRING_MULTIPLE_TYPES': `search text is made up of multiple request types`, } as const @@ -45,7 +50,7 @@ export type SearchRequestTypeId = Extract) { this._searchText = searchText - // this._searchOptions = { - // useCache: options?.useCache ?? true, - // track_total_hits: options?.track_total_hits ?? true, - // default_operator: options?.default_operator ?? "AND", - // metadataOnly: options?.metadataOnly ?? false, - // fields: options?.fields ?? [], - // sort: options?.sort ?? [{ - // "cveMetadata.cveId.keyword": { "order": "desc" } - // }], - // from: options?.from ?? 0, - // size: options?.size ?? 25, - // } - // this._query = {} } @@ -128,15 +120,15 @@ export class SearchRequest { } else { // general search text processing - this._searchText?.trim(); + this._searchText = this._searchText?.trim(); const cleanedSearchText = SearchRequest.replaceRepeatingSymbols(this._searchText); const text: string[] = SearchRequest.tokenizeSearchText(cleanedSearchText); - let type; let overallType: SearchRequestTypeId = 'SEARCH_STRING_UNKNOWN_TYPE'; let errorIds: CveErrorId[] = [] let newSearchText = '' + let searchTokens: SearchToken[] = [] text.forEach(token => { - type = SearchRequest.findSearchRequestType(token) + let type = SearchRequest.findSearchRequestType(token); // post-processing based on type switch (type) { case 'SEARCH_AS_CVE_ID': @@ -170,10 +162,14 @@ export class SearchRequest { result = CveResult.ok({}, [SearchRequestType[type]]); break; } + // token at this point has been properly decorated ready to be used + // in opensearch + let searchToken = new SearchToken(token, type); newSearchText = `${newSearchText} ${token}` if (overallType === 'SEARCH_STRING_UNKNOWN_TYPE') { - overallType = type - } else if (overallType !== type) { + overallType = type; + } + else if (overallType !== type) { overallType = 'SEARCH_STRING_MULTIPLE_TYPES' } }) @@ -197,7 +193,7 @@ export class SearchRequest { // matches repeats of anything except language characters and numbers and ... and --- static repeatingSymbolsRegex = /([^\p{L}0-9.\-])\1{2,}/gu; // not greedy version /(.+?)\1+/ - static repeatingPatternsRegex = /(.+)\1+/gu + // static repeatingPatternsRegex = /(.+)\1+/gu /** checks for repeating symbols and optionally removes them @@ -205,7 +201,11 @@ export class SearchRequest { * @returns true iff there are repeating symbols */ static hasRepeatingSymbols(searchText: string): boolean { - return this.repeatingSymbolsRegex.test(searchText); + // return this.repeatingSymbolsRegex.test(searchText); + // creating a new regex of repeatingSymbolsRegex each time because + // it has a global flag + const regex = new RegExp(SearchRequest.repeatingSymbolsRegex); + return regex.test(searchText); } @@ -214,7 +214,23 @@ export class SearchRequest { * @returns string after repeating symbols have been removed */ static replaceRepeatingSymbols(searchText: string): string { - return searchText.replaceAll(SearchRequest.repeatingSymbolsRegex, ""); + // return searchText.replaceAll(SearchRequest.repeatingSymbolsRegex, ""); + // creating a new regex of repeatingSymbolsRegex each time because + // it has a global flag + const regex = new RegExp(SearchRequest.repeatingSymbolsRegex); + return searchText.replaceAll(regex, ""); + } + + + static isDateString(searchText: string | null): boolean { + // performance-optimized and safer version of CVE Schema 5.1 regex for dates generated by gemini 2.5 pro + const regex = /^((?:(?:2000|2400|2800)|(?:(?:19|2\d)(?:0[48]|[2468][048]|[13579][26])))-02-29|(?:(?:19|2\d)\d{2})-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|02-(?:0[1-9]|1\d|2[0-8])))$/; + if (!searchText || typeof searchText !== 'string' || searchText.length <= 0) { + return false; + } + else { + return regex.test(searchText); + } } @@ -444,6 +460,9 @@ export class SearchRequest { else if (SearchRequest.isQuotedString(searchText)) { return 'SEARCH_PHRASE'; } + else if (SearchRequest.isDateString(searchText)) { + return 'SEARCH_DATE'; + } else if (searchText.includes('*')) { return 'SEARCH_AS_WILDCARD_ASTERISK' } diff --git a/src/search/SearchToken.ts b/src/search/SearchToken.ts new file mode 100644 index 0000000..b2c934b --- /dev/null +++ b/src/search/SearchToken.ts @@ -0,0 +1,30 @@ +import { SearchRequestTypeId } from './SearchRequest.js'; + +/** SearchToken is the user's search string that has been tokenized, analyzed, cateogrized, and decorated + */ +export class SearchToken { + + /** the processed text string */ + private _text: string; + private set text(value: string) { + this._text = value; + } + public get text(): string { + return this._text; + } + + /** the token type */ + private _typeId: SearchRequestTypeId; + public get typeId(): SearchRequestTypeId { + return this._typeId; + } + public set typeId(value: SearchRequestTypeId) { + this._typeId = value; + } + + + constructor(text: string, typeId: SearchRequestTypeId) { + this._text = text + this._typeId = typeId + } +} \ No newline at end of file diff --git a/src/search/__snapshots__/SearchQueryBuilder.test.unit.ts.snap b/src/search/__snapshots__/SearchQueryBuilder.test.unit.ts.snap index ac1cf5c..bcea759 100644 --- a/src/search/__snapshots__/SearchQueryBuilder.test.unit.ts.snap +++ b/src/search/__snapshots__/SearchQueryBuilder.test.unit.ts.snap @@ -95,6 +95,53 @@ CveResult { } `; +exports[`SearchQueryBuilder (2023-12-21,{"track_total_hits":true})..buildQuery() correctly returns the expected query 1`] = ` +CveResult { + "data": Object { + "processedSearchText": "2023-12-21", + "q": Object { + "from": 0, + "query": Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "range": Object { + "containers.cna.providerMetadata.dateUpdated": Object { + "gte": "2023-12-21||/d", + }, + }, + }, + Object { + "range": Object { + "containers.cna.timeline.time": Object { + "gte": "2023-12-21||/d", + }, + }, + }, + ], + }, + }, + "size": 25, + "sort": Array [ + Object { + "cveMetadata.cveId.keyword": Object { + "order": "desc", + }, + }, + ], + "track_total_hits": true, + }, + "searchTextType": "SEARCH_DATE", + }, + "errors": undefined, + "notes": Array [ + "search text is a date (ISO 8601)", + ], + "status": "ok", +} +`; + exports[`SearchQueryBuilder (CAPEC-64,{"default_operator":"OR"})..buildQuery() correctly returns the expected query 1`] = ` CveResult { "data": Object { diff --git a/src/search/test_cases/__snapshots__/search_dates.test.e2e.ts.snap b/src/search/test_cases/__snapshots__/search_dates.test.e2e.ts.snap new file mode 100644 index 0000000..cdb0619 --- /dev/null +++ b/src/search/test_cases/__snapshots__/search_dates.test.e2e.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Date Search (e2e tests) BasicSearchManager.search() search('1999') should return cves including undefined 1`] = ` +Array [ + "CVE-1999-0529", +] +`; + +exports[`Date Search (e2e tests) BasicSearchManager.search() search('2022-01-11') should return cves including CVE-2022-21963 1`] = ` +Array [ + "CVE-2021-45052", + "CVE-2022-21833", + "CVE-2022-21834", + "CVE-2022-21836", + "CVE-2022-21843", + "CVE-2022-21862", + "CVE-2022-21864", + "CVE-2022-21867", + "CVE-2022-21880", + "CVE-2022-21881", + "CVE-2022-21883", + "CVE-2022-21894", + "CVE-2022-21900", + "CVE-2022-21903", + "CVE-2022-21904", + "CVE-2022-21908", + "CVE-2022-21916", + "CVE-2022-21924", + "CVE-2022-21958", + "CVE-2022-21960", + "CVE-2022-21961", + "CVE-2022-21962", + "CVE-2022-21963", +] +`; + +exports[`Date Search (e2e tests) BasicSearchManager.search() search('2024-06-14') should return cves including CVE-2024-6006 1`] = ` +Array [ + "CVE-2024-31162", + "CVE-2024-36155", + "CVE-2024-5981", + "CVE-2024-6006", +] +`; + +exports[`Date Search (e2e tests) BasicSearchManager.search() search('2025-01-11') should return cves including CVE-2024-42169 1`] = ` +Array [ + "CVE-2024-42169", + "CVE-2025-23114", +] +`; diff --git a/src/search/test_cases/search_dates.test.e2e.ts b/src/search/test_cases/search_dates.test.e2e.ts new file mode 100644 index 0000000..f69522d --- /dev/null +++ b/src/search/test_cases/search_dates.test.e2e.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from '@jest/globals'; + +import { BasicSearchManager } from "../BasicSearchManager.js"; +import { SearchResultData } from "../SearchResultData.js"; +import { SearchProviderSpec } from '../../adapters/search/SearchAdapter.js'; +import { SearchRequest, SearchRequestTypeId } from '../SearchRequest.js'; + +describe('Date Search (e2e tests)', () => { + const searchProviderSpec = SearchProviderSpec.getDefaultSearchProviderSpec(); + + // ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- + + describe.only('BasicSearchManager.search()', () => { + // expected defaults to false in this series! + const testCases: Array<{ input: string; expected?: number; first?: string; includes?: string; snapshot?: boolean; }> = [ + // valid dates + { input: `2022-01-11`, expected: 23, includes: "CVE-2022-21963", snapshot: true }, + // { input: `2025-01-11T02:31:22.611Z`, expected: 1, includes: "CVE-2024-42169", snapshot: true }, + { input: `2024-06-14`, expected: 4, includes: "CVE-2024-6006", snapshot: true }, + { input: `2025-01-11`, expected: 2, includes: "CVE-2024-42169", snapshot: true }, + { input: `1999`, expected: 0, snapshot: true }, + // { input: `2022`, expected: 0, snapshot: true }, + // { input: `2022-01`, expected: 12, includes: "CVE-2022-40621", snapshot: true }, // why search by month results in less than search by date in same month + // invalid dates + + ]; + + testCases.forEach(({ input, expected, first, includes, snapshot }) => { + it(`search('${input}') should return cves including ${includes}`, async () => { + const searchManager = new BasicSearchManager(searchProviderSpec); + const resp = await searchManager.search(input, { + track_total_hits: true, + metadataOnly: true, +  from: 0, + size: 2000 + }); + if (!resp.isOk()) { + console.log(`resp: ${JSON.stringify(resp, null, 2)}`); + } + expect(resp.isOk()).toBeTruthy(); + if (resp['data']) { + const dat = resp['data']['hits']['hits']; + let retlist: {}[] = dat + let cves: string[] = [] + const cveIdMap = retlist.map(cve => { + cves.push(cve["_id"]) + }) + console.log(`cves: ${JSON.stringify(cves, null, 2)}`) + if (snapshot) { + expect(cves.sort()).toMatchSnapshot(); + } + if ( expected ) { + expect(resp['data']['hits']['total']['value']).toBe(expected); + } + if (first) { + expect(dat[0]["_id"]).toBe(first); + } + if ( includes ) { + // let found = retlist.find( cve => { + // return cve["_id"] === includes + // }) + let found = retlist.some(item => item["_id"] === includes) + expect( found ).toBeTruthy() + } + } + }); + }); + }); + + +}); \ No newline at end of file diff --git a/src/search/test_cases/search_dates.test.unit.ts b/src/search/test_cases/search_dates.test.unit.ts new file mode 100644 index 0000000..7d2dbdc --- /dev/null +++ b/src/search/test_cases/search_dates.test.unit.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from '@jest/globals'; +import { SearchRequest, SearchRequestTypeId } from '../SearchRequest.js'; + +describe('Date Search (unit tests)', () => { + // ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- + + describe('SearchRequest.tokenizeSearchText()', () => { + // expected defaults to false in this series! + const testCases: Array<{ input?: string | null; expected?: boolean; }> = [ + // valid dates + { input: `2025-09-18`, expected: true }, + { input: `2024-02-29`, expected: true }, // leap year + // invalid dates (expected defaults to false in this series, so we can leave expect out) + { input: null, expected: false }, + { input: undefined }, + { input: `` }, // empty string + { input: ` ` }, // whitespace + { input: `2025` }, // year only + { input: `2025-01` }, // year and month only + { input: `2025-09-01/2025-09-18` }, // date range + { input: `2025-09-18T12:00:00:00.000Z` }, // datetime Z + { input: `2025-09-18T12:00:00:00.000+05:00` }, // datetime offset + { input: `2025-02-29` }, // not a leap year + { input: `1899-12-30` }, // out of range date + { input: `T12:00:00:00.000Z` }, // ISO 8601 time + + // malformed dates + { input: '2023-4-01' }, // month not zero‑padded + { input: '23-04-01' }, // year not 4‑digit + { input: '2023-13-01' }, // month > 12 + { input: '2023-00-10' }, // month 00 + { input: '2023-02-30' }, // day > 28/29 + { input: '2023-01-00' }, // day 00 + + // non‑ISO strings + { input: 'April 1, 2023' }, + { input: '2023/04/01' }, + { input: '19/01/2023' }, + { input: '01/19/2023' }, + + // non-dates that can look like dates + + // // malformed times + // '2023-04-01T24:00', // hour 24 not allowed in our pattern + // '2023-04-01T14:60', // minute 60 + // '2023-04-01T14:30:60',// second 60 + // '2023-04-01T14', // incomplete time + // // wrong timezone + // '2023-04-01T14:30+25:00', // offset hour > 23 + // '2023-04-01T14:30+02:60', // offset minute > 59 + // '2023-04-01T14:30+02', // missing :mm + // // missing separator in interval + // '2023-04-01 2023-04-10', + // // extra characters + // '2023-04-01T14:30Zextra', + ]; + + testCases.forEach(({ input, expected }) => { + it(`isDateString() should match proper dates and recognize non-dates --> '${input}': ${expected ?? false}`, () => { + const result = SearchRequest.isDateString(input); + expected = expected ?? false; + expect(result).toEqual(expected); + }); + }); + }); + + +}); \ No newline at end of file