|
28 | 28 | * mode `TursoDriver.toRemoteFilter` rebuilds every node, so no mark reaches |
29 | 29 | * this transport and even an author-marked `where` is withheld there: the |
30 | 30 | * contract's declared fail-closed direction, pinned so it is a decision and |
31 | | - * not an accident. |
| 31 | + * not an accident. [#20094] A `$between` that is not two bounds joins that |
| 32 | + * half in a table of its own: it reached neither seam before, because the |
| 33 | + * driver refused it while lowering it, ahead of the transport. |
32 | 34 | */ |
33 | 35 |
|
34 | 36 | import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; |
@@ -165,13 +167,24 @@ const ARMS: readonly Door[] = [ |
165 | 167 | klass: 'a key that is not an operator', |
166 | 168 | }, |
167 | 169 | { |
168 | | - // Unreachable through `TursoDriver` (it lowers `$between` first) — withheld |
169 | | - // all the same, so the class has no exception. |
| 170 | + // A WELL-FORMED range is unreachable through `TursoDriver` (it lowers every |
| 171 | + // two-bound `$between` first) — withheld all the same, so the class has no |
| 172 | + // exception. |
170 | 173 | builder: 'unsupportedOperator', |
171 | 174 | where: () => ({ [POLICY_COL]: { $between: [1, 2] } }), |
172 | 175 | secrets: [POLICY_COL], |
173 | 176 | klass: 'must be lowered to $gte/$lte', |
174 | 177 | }, |
| 178 | + { |
| 179 | + // [#20094] A range that is not two bounds: `TursoDriver` hands it over |
| 180 | + // un-lowered, so this arm IS reachable through the driver for it — with |
| 181 | + // `driver-sql`'s class statement for the same mistake, and the comparand |
| 182 | + // in the sink beside the field. |
| 183 | + builder: 'unsupportedOperator', |
| 184 | + where: () => ({ [POLICY_COL]: { $between: [SECRET_NUM] } }), |
| 185 | + secrets: [POLICY_COL, String(SECRET_NUM)], |
| 186 | + klass: 'requires a [min, max] value array', |
| 187 | + }, |
175 | 188 | { |
176 | 189 | // The misplaced-field-operator tail: which tail applied says whether the |
177 | 190 | // key is a field operator, so both tails share ONE withheld sentence. |
@@ -461,3 +474,150 @@ describe('[#20039] TursoDriver LOCAL and REMOTE withhold these classes alike', ( |
461 | 474 | }); |
462 | 475 | } |
463 | 476 | }); |
| 477 | + |
| 478 | +// ── Half 3b: a `$between` that is not two bounds, one table over both faces ─── |
| 479 | + |
| 480 | +/** |
| 481 | + * [#20094] REMOTE mode refused a malformed range in |
| 482 | + * `TursoDriver.toRemoteFieldSpec`, which runs before the transport and so |
| 483 | + * outside its seam: a bare `Error` with no `code` and no `status` (an |
| 484 | + * unclassified 500 over REST) that named `'deal.<field>'` and echoed the |
| 485 | + * comparand to every caller, a `'policy'`-marked subtree included. LOCAL mode |
| 486 | + * answers `INVALID_FILTER` / 400 with the field withheld. The lowering now |
| 487 | + * hands such a range over as written and the transport's `$between` arm |
| 488 | + * refuses it, so this is one table over both faces: every shape, every caller |
| 489 | + * class, `find` and `count`. The two well-formed controls hold the lowering |
| 490 | + * itself still — the date one turns on a row only the whole-day upper bound |
| 491 | + * admits. |
| 492 | + */ |
| 493 | +describe('[#20094] TursoDriver LOCAL and REMOTE refuse a $between that is not two bounds alike', () => { |
| 494 | + const RANGE_COL = 'secret_policy_amount'; |
| 495 | + const OBJECT = { |
| 496 | + name: 'deal', |
| 497 | + fields: { [RANGE_COL]: { type: 'number' }, closed_at: { type: 'datetime' } }, |
| 498 | + }; |
| 499 | + const ROWS = [ |
| 500 | + { id: 'r1', [RANGE_COL]: 5, closed_at: '2026-01-01T00:00:00.000Z' }, |
| 501 | + // Inside the window ONLY because a bare-day max covers its whole day. |
| 502 | + { id: 'r2', [RANGE_COL]: 15, closed_at: '2026-01-31T13:45:00.000Z' }, |
| 503 | + { id: 'r3', [RANGE_COL]: 20, closed_at: '2026-02-01T00:00:00.000Z' }, |
| 504 | + { id: 'r4', [RANGE_COL]: 25, closed_at: '2025-12-31T23:59:59.000Z' }, |
| 505 | + ]; |
| 506 | + let local: TursoDriver; |
| 507 | + let remote: TursoDriver; |
| 508 | + let stub: LibsqlSqliteStub; |
| 509 | + |
| 510 | + beforeAll(async () => { |
| 511 | + local = new TursoDriver({ url: ':memory:' }); |
| 512 | + expect(local.transportMode).toBe('local'); |
| 513 | + await local.initObjects([OBJECT]); |
| 514 | + stub = makeLibsqlSqliteStub(); |
| 515 | + remote = new TursoDriver({ url: 'libsql://between-arity.turso.io', client: asLibsqlClient(stub) }); |
| 516 | + await remote.connect(); |
| 517 | + expect(remote.transportMode).toBe('remote'); |
| 518 | + await remote.syncSchema(OBJECT.name, OBJECT); |
| 519 | + for (const row of ROWS) { |
| 520 | + await local.create(OBJECT.name, { ...row }, { bypassTenantAudit: true }); |
| 521 | + await remote.create(OBJECT.name, { ...row }); |
| 522 | + } |
| 523 | + }); |
| 524 | + |
| 525 | + afterAll(async () => { |
| 526 | + await local.disconnect(); |
| 527 | + await remote.disconnect(); |
| 528 | + stub.close(); |
| 529 | + }); |
| 530 | + |
| 531 | + type Mark = 'unmarked' | 'policy' | 'author'; |
| 532 | + type Op = 'find' | 'count'; |
| 533 | + |
| 534 | + /** `echo` is the literal the comparand put on the wire at base, where it has one. */ |
| 535 | + const RANGES: ReadonlyArray<[label: string, range: () => unknown, echo: string | null]> = [ |
| 536 | + ['one bound', () => [SECRET_NUM], String(SECRET_NUM)], |
| 537 | + ['three bounds', () => [SECRET_NUM, SECRET_NUM + 1, SECRET_NUM + 2], String(SECRET_NUM)], |
| 538 | + ['a number', () => SECRET_NUM, String(SECRET_NUM)], |
| 539 | + ['a string', () => SECRET, SECRET], |
| 540 | + ['null', () => null, null], |
| 541 | + ['an object', () => ({}), null], |
| 542 | + ['an empty list', () => [], null], |
| 543 | + ]; |
| 544 | + |
| 545 | + const whereOf = (range: () => unknown, mark: Mark): Record<string, unknown> => { |
| 546 | + const where = { [RANGE_COL]: { $between: range() } }; |
| 547 | + return mark === 'unmarked' ? where : markFilterSubtreeProvenance(where, mark); |
| 548 | + }; |
| 549 | + |
| 550 | + const refusalOf = async (driver: TursoDriver, op: Op, where: unknown) => { |
| 551 | + const logger = (driver as unknown as { logger: { warn: (...args: unknown[]) => void } }).logger; |
| 552 | + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); |
| 553 | + const execute = vi.spyOn(stub, 'execute'); |
| 554 | + try { |
| 555 | + const query = { where } as DriverQuery; |
| 556 | + const err = await (op === 'find' ? driver.find(OBJECT.name, query) : driver.count(OBJECT.name, query)).then( |
| 557 | + () => null, |
| 558 | + (e: unknown) => e as WireBearingError, |
| 559 | + ); |
| 560 | + expect(err, `${driver.transportMode} ${op} answered a malformed range`).not.toBeNull(); |
| 561 | + expect(err!.code, driver.transportMode).toBe('INVALID_FILTER'); |
| 562 | + expect(err!.status, driver.transportMode).toBe(400); |
| 563 | + expect(Object.keys(err!).sort(), driver.transportMode).toEqual(['code', 'status']); |
| 564 | + if (driver === remote) expect(execute, 'a refused filter must not execute a statement').not.toHaveBeenCalled(); |
| 565 | + return { message: err!.message, log: warn.mock.calls.map((call) => String(call[0])).join('\n') }; |
| 566 | + } finally { |
| 567 | + warn.mockRestore(); |
| 568 | + execute.mockRestore(); |
| 569 | + } |
| 570 | + }; |
| 571 | + |
| 572 | + for (const [label, range, echo] of RANGES) { |
| 573 | + for (const op of ['find', 'count'] as const) { |
| 574 | + it(`${label}, ${op}: unmarked and policy-marked ⇒ one class statement on both faces, operands in the log`, async () => { |
| 575 | + for (const mark of ['unmarked', 'policy'] as const) { |
| 576 | + const faces = { |
| 577 | + local: await refusalOf(local, op, whereOf(range, mark)), |
| 578 | + remote: await refusalOf(remote, op, whereOf(range, mark)), |
| 579 | + }; |
| 580 | + for (const [face, { message, log }] of Object.entries(faces)) { |
| 581 | + expect(message, `${face} ${mark}`).toContain('requires a [min, max] value array'); |
| 582 | + expect(message, `${face} ${mark}`).not.toContain(RANGE_COL); |
| 583 | + if (echo) expect(message, `${face} ${mark}`).not.toContain(echo); |
| 584 | + expect(log, `${face} ${mark}: the log lost the field`).toContain(RANGE_COL); |
| 585 | + } |
| 586 | + // The remote sentence is the local one behind the transport's prefix, |
| 587 | + // less the tracker id the local sentence carries: runtime text carries |
| 588 | + // none (`pnpm check:doc-authoring`), so the copy could not. |
| 589 | + expect(faces.remote.message, mark).toBe( |
| 590 | + `[RemoteTransport] ${faces.local.message.replace(/ \(#\d+\)/g, '')}`, |
| 591 | + ); |
| 592 | + // What the wire stopped echoing is in the remote sink. |
| 593 | + if (echo) expect(faces.remote.log, `${mark}: the sink lost the comparand`).toContain(echo); |
| 594 | + } |
| 595 | + }); |
| 596 | + |
| 597 | + it(`${label}, ${op}: author-marked ⇒ LOCAL restores the field, REMOTE stays withheld`, async () => { |
| 598 | + expect((await refusalOf(local, op, whereOf(range, 'author'))).message).toContain(RANGE_COL); |
| 599 | + // `TursoDriver.toRemoteFilter` rebuilds every node, so no mark reaches |
| 600 | + // the transport and an author reads the unmarked answer — the declared |
| 601 | + // fail-closed direction, the same as every other remote refusal. |
| 602 | + expect((await refusalOf(remote, op, whereOf(range, 'author'))).message).toBe( |
| 603 | + (await refusalOf(remote, op, whereOf(range, 'unmarked'))).message, |
| 604 | + ); |
| 605 | + }); |
| 606 | + } |
| 607 | + } |
| 608 | + |
| 609 | + const CONTROLS: ReadonlyArray<[label: string, where: Record<string, unknown>, expected: string[]]> = [ |
| 610 | + ['numeric', { [RANGE_COL]: { $between: [10, 20] } }, ['r2', 'r3']], |
| 611 | + ['bare days on a datetime column', { closed_at: { $between: ['2026-01-01', '2026-01-31'] } }, ['r1', 'r2']], |
| 612 | + ]; |
| 613 | + |
| 614 | + for (const [label, where, expected] of CONTROLS) { |
| 615 | + it(`well-formed control, ${label}: both faces return the same rows, and count agrees`, async () => { |
| 616 | + for (const driver of [local, remote]) { |
| 617 | + const rows = await driver.find(OBJECT.name, { where } as DriverQuery); |
| 618 | + expect(rows.map((row) => String(row.id)).sort(), driver.transportMode).toEqual(expected); |
| 619 | + expect(await driver.count(OBJECT.name, { where } as DriverQuery), driver.transportMode).toBe(expected.length); |
| 620 | + } |
| 621 | + }); |
| 622 | + } |
| 623 | +}); |
0 commit comments