|
1 | 1 | import { describe, expect, it } from 'vitest'; |
2 | 2 |
|
3 | | -import { celEngine, temporalEqualityFields } from './cel-engine'; |
| 3 | +import { celEngine, rewriteTemporalEquality } from './cel-engine'; |
4 | 4 | import { CEL_STDLIB_FUNCTIONS } from './validate'; |
5 | 5 | import type { Expression } from '@objectstack/spec'; |
6 | 6 |
|
@@ -169,20 +169,23 @@ describe('celEngine', () => { |
169 | 169 | .toEqual({ ok: true, value: true }); |
170 | 170 | }); |
171 | 171 |
|
172 | | - it('KNOWN GAP: bare `date-string == today()` silently returns false (cel-js equality)', () => { |
173 | | - // Characterization guard, NOT an endorsement. cel-js's `isEqual` |
174 | | - // (overloads.js) hard-codes `string == X` to false and never consults a |
175 | | - // registered overload, so a bare `Field.date` string compared with `==` |
176 | | - // silently misses — independent of timezone (fails identically at UTC). |
177 | | - // The fix must hydrate date fields to Date in the data layer (where field |
178 | | - // types are known); tracked as a separate follow-up. Authors should use |
179 | | - // the idioms in the test above until then. If this starts returning true, |
180 | | - // the follow-up landed — update/remove this guard. |
181 | | - const now = new Date('2026-11-02T04:30:00Z'); |
182 | | - const r = celEngine.evaluate(cel('record.due_date == today()'), { |
183 | | - now, timezone: 'America/New_York', record: { due_date: '2026-11-01' }, |
184 | | - }); |
185 | | - expect(r).toEqual({ ok: true, value: false }); |
| 172 | + it('bare `date-string == today()` now matches (the #3183 runtime fix)', () => { |
| 173 | + // Previously the KNOWN GAP: cel-js's `isEqual` hard-codes `string == X` to |
| 174 | + // false, so a bare `Field.date` string never equalled the Timestamp from |
| 175 | + // today(). The engine now rewrites the field operand to `date(record.d)` |
| 176 | + // (AST temporal-comparison rewrite, #3183), so it compares two Timestamps |
| 177 | + // and matches on the reference-tz calendar day. |
| 178 | + const now = new Date('2026-11-02T04:30:00Z'); // Nov 1 in NY |
| 179 | + const ny = { now, timezone: 'America/New_York', record: { due_date: '2026-11-01' } }; |
| 180 | + expect(celEngine.evaluate(cel('record.due_date == today()'), ny)) |
| 181 | + .toEqual({ ok: true, value: true }); |
| 182 | + // The `!=` dual is now correctly false for a same-day record. |
| 183 | + expect(celEngine.evaluate(cel('record.due_date != today()'), ny)) |
| 184 | + .toEqual({ ok: true, value: false }); |
| 185 | + // A different day still compares unequal. |
| 186 | + expect(celEngine.evaluate(cel('record.due_date == today()'), { |
| 187 | + now, timezone: 'America/New_York', record: { due_date: '2026-10-31' }, |
| 188 | + })).toEqual({ ok: true, value: false }); |
186 | 189 | }); |
187 | 190 | }); |
188 | 191 |
|
@@ -511,35 +514,78 @@ describe('celEngine', () => { |
511 | 514 | }); |
512 | 515 | }); |
513 | 516 |
|
514 | | - // #3183 — AST walk backing the date-equality guardrail. Returns field names |
515 | | - // compared with `==`/`!=` directly against a temporal function; the validator |
516 | | - // filters these by field type. AST-based, so no ReDoS on adversarial source. |
517 | | - describe('temporalEqualityFields (#3183)', () => { |
518 | | - it('finds the field on either side, for all four temporal functions', () => { |
519 | | - expect(temporalEqualityFields('record.due == today()')).toEqual(['due']); |
520 | | - expect(temporalEqualityFields('today() != record.due')).toEqual(['due']); |
521 | | - expect(temporalEqualityFields('record.due == daysFromNow(3)')).toEqual(['due']); |
522 | | - expect(temporalEqualityFields('record.due != daysAgo(7)')).toEqual(['due']); |
523 | | - expect(temporalEqualityFields('previous.due == now()')).toEqual(['due']); |
524 | | - expect(temporalEqualityFields('due == today()')).toEqual(['due']); // bare (flattened) |
| 517 | + // #3183 — AST rewrite backing the runtime date-equality fix: wrap a field |
| 518 | + // operand compared with `==`/`!=` against a temporal function in `date(...)`. |
| 519 | + describe('rewriteTemporalEquality (#3183)', () => { |
| 520 | + it('wraps the field operand on either side, for all four temporal functions', () => { |
| 521 | + expect(rewriteTemporalEquality('record.due == today()')).toBe('date(record.due) == today()'); |
| 522 | + expect(rewriteTemporalEquality('today() != record.due')).toBe('today() != date(record.due)'); |
| 523 | + expect(rewriteTemporalEquality('record.due == daysFromNow(3)')).toBe('date(record.due) == daysFromNow(3)'); |
| 524 | + expect(rewriteTemporalEquality('record.due != daysAgo(7)')).toBe('date(record.due) != daysAgo(7)'); |
| 525 | + expect(rewriteTemporalEquality('previous.due == now()')).toBe('date(previous.due) == now()'); |
| 526 | + expect(rewriteTemporalEquality('due == today()')).toBe('date(due) == today()'); // bare (flattened) |
| 527 | + }); |
| 528 | + |
| 529 | + it('leaves the working idioms, ordering comparisons, and non-temporal equality untouched', () => { |
| 530 | + for (const src of [ |
| 531 | + 'date(record.due) == today()', // already coerced — idempotent |
| 532 | + 'record.due >= today()', // ordering (already works) |
| 533 | + 'daysBetween(today(), record.due) == 0', // integer compare |
| 534 | + 'record.a == record.b', // no temporal |
| 535 | + 'record.due == "2026-06-20"', // string literal, no temporal |
| 536 | + ]) { |
| 537 | + expect(rewriteTemporalEquality(src)).toBe(src); |
| 538 | + } |
| 539 | + }); |
| 540 | + |
| 541 | + it('rewrites per-occurrence — a mixed literal+temporal expression keeps the literal intact', () => { |
| 542 | + expect(rewriteTemporalEquality('record.d == "2026-06-20" || record.d == today()')) |
| 543 | + .toBe('record.d == "2026-06-20" || date(record.d) == today()'); |
525 | 544 | }); |
526 | 545 |
|
527 | | - it('returns nothing for the working idioms or ordering comparisons', () => { |
528 | | - expect(temporalEqualityFields('date(record.due) == today()')).toEqual([]); |
529 | | - expect(temporalEqualityFields('record.due >= today()')).toEqual([]); |
530 | | - expect(temporalEqualityFields('daysBetween(today(), record.due) == 0')).toEqual([]); |
531 | | - expect(temporalEqualityFields('record.a == record.b')).toEqual([]); |
| 546 | + it('returns the source unchanged (no throw) on adversarial input — no ReDoS', () => { |
| 547 | + // AST-based + a plain-`includes` gate; the parse either bails or is linear. |
| 548 | + expect(rewriteTemporalEquality('$'.repeat(5000))).toBe('$'.repeat(5000)); |
| 549 | + expect(rewriteTemporalEquality('now('.repeat(2000))).toBe('now('.repeat(2000)); |
| 550 | + }); |
| 551 | + }); |
| 552 | + |
| 553 | + // #3183 — the end-to-end runtime behavior the rewrite delivers: a `Field.date` |
| 554 | + // string operand now matches a temporal function under `==`/`!=`, while string |
| 555 | + // literals and already-typed operands are unaffected. |
| 556 | + describe('date-string == temporal runtime fix (#3183)', () => { |
| 557 | + const now = new Date('2026-06-20T08:00:00Z'); |
| 558 | + const rec = (due: unknown) => ({ now, record: { due } }); |
| 559 | + |
| 560 | + it('a date-only string field == today() matches on the same day', () => { |
| 561 | + expect(celEngine.evaluate(cel('record.due == today()'), rec('2026-06-20'))) |
| 562 | + .toEqual({ ok: true, value: true }); |
| 563 | + expect(celEngine.evaluate(cel('record.due == today()'), rec('2026-06-19'))) |
| 564 | + .toEqual({ ok: true, value: false }); |
| 565 | + // same-day record → `!=` is correctly false (previously silently true) |
| 566 | + expect(celEngine.evaluate(cel('record.due != today()'), rec('2026-06-20'))) |
| 567 | + .toEqual({ ok: true, value: false }); |
532 | 568 | }); |
533 | 569 |
|
534 | | - it('de-duplicates and finds fields nested in a compound predicate', () => { |
535 | | - expect(temporalEqualityFields('record.due == today() || record.due == daysFromNow(1)')).toEqual(['due']); |
536 | | - expect(temporalEqualityFields('record.a == today() && b != now()').sort()).toEqual(['a', 'b']); |
| 570 | + it('a string literal comparison is unchanged, even mixed with a temporal one', () => { |
| 571 | + // Pre-existing behavior: string == string literal works. |
| 572 | + expect(celEngine.evaluate(cel('record.due == "2026-06-20"'), rec('2026-06-20'))) |
| 573 | + .toEqual({ ok: true, value: true }); |
| 574 | + // Mixed: literal clause AND temporal clause both correct for a same-day record. |
| 575 | + expect(celEngine.evaluate(cel('record.due == "2026-06-20" || record.due == today()'), rec('2026-06-20'))) |
| 576 | + .toEqual({ ok: true, value: true }); |
| 577 | + // Mixed, record on neither day: both clauses false. |
| 578 | + expect(celEngine.evaluate(cel('record.due == "2026-06-20" || record.due == today()'), rec('2026-06-18'))) |
| 579 | + .toEqual({ ok: true, value: false }); |
537 | 580 | }); |
538 | 581 |
|
539 | | - it('is linear on adversarial input (the CodeQL ReDoS repros) and returns []', () => { |
540 | | - // These would drive the previous regex O(n²); the AST walk parses or bails fast. |
541 | | - expect(temporalEqualityFields('$'.repeat(5000))).toEqual([]); |
542 | | - expect(temporalEqualityFields('now('.repeat(2000))).toEqual([]); |
| 582 | + it('an already-Date operand and non-date/null operands are unaffected (graceful date() coercion)', () => { |
| 583 | + expect(celEngine.evaluate(cel('record.due == today()'), rec(new Date('2026-06-20T00:00:00Z')))) |
| 584 | + .toEqual({ ok: true, value: true }); |
| 585 | + expect(celEngine.evaluate(cel('record.due == today()'), rec('not-a-date'))) |
| 586 | + .toEqual({ ok: true, value: false }); |
| 587 | + expect(celEngine.evaluate(cel('record.due == today()'), rec(null))) |
| 588 | + .toEqual({ ok: true, value: false }); |
543 | 589 | }); |
544 | 590 | }); |
545 | 591 | }); |
0 commit comments