|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #25229 — `findData`'s list-query normalizer coerced the page window with a |
| 5 | + * bare `Number(...)` and no range check. |
| 6 | + * |
| 7 | + * Every list door reaches that one statement with the window unvalidated: |
| 8 | + * `GET /data/:object` hands its querystring over as strings, `POST |
| 9 | + * /data/:object/query`'s body schema checks that `limit` / `offset` are |
| 10 | + * numbers but not their sign, and the runtime dispatcher forwards both. So a |
| 11 | + * `NaN` (`?$top=abc`) or a negative limit reached the driver, which drops a |
| 12 | + * window it cannot read — every row, under a 200 — and a negative offset |
| 13 | + * reached knex's `QueryBuilder.offset`, which throws: a 500. |
| 14 | + * |
| 15 | + * ## Two assertion classes |
| 16 | + * |
| 17 | + * 1. **REFUSAL** — each malformed window, in every spelling, answers `400` |
| 18 | + * `INVALID_REQUEST` naming the spelling the caller wrote, and the engine is |
| 19 | + * never reached. `code` AND `status` are asserted: the unfixed normalizer |
| 20 | + * never threw for these, so a bare `toThrow()` would only be half a pin. |
| 21 | + * 2. **PRESERVATION** — a well-formed window reaches `engine.find` as the |
| 22 | + * integers it always did, `0` included. Green in both directions, so a |
| 23 | + * guard, not evidence: it is what a refusal is cheapest to break. |
| 24 | + * |
| 25 | + * The door-level half (both REST routes over a real `SqlDriver`) is |
| 26 | + * `packages/rest/src/data-page-window-door.test.ts`. |
| 27 | + */ |
| 28 | + |
| 29 | +import { describe, it, expect, vi } from 'vitest'; |
| 30 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 31 | + |
| 32 | +const SCHEMA = { |
| 33 | + name: 'invoice', |
| 34 | + nameField: 'name', |
| 35 | + fields: { |
| 36 | + name: { name: 'name', type: 'text' }, |
| 37 | + status: { name: 'status', type: 'text' }, |
| 38 | + }, |
| 39 | +}; |
| 40 | + |
| 41 | +function makeProtocol() { |
| 42 | + const find = vi.fn(async () => [] as unknown[]); |
| 43 | + const engine = { |
| 44 | + registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) }, |
| 45 | + find, |
| 46 | + aggregate: vi.fn(async () => [] as unknown[]), |
| 47 | + count: vi.fn(async () => 0), |
| 48 | + }; |
| 49 | + return { p: new ObjectStackProtocolImplementation(engine as any), find }; |
| 50 | +} |
| 51 | + |
| 52 | +/** The option bag `engine.find` was actually handed, for an accepted query. */ |
| 53 | +async function optionsFor(query: Record<string, unknown>): Promise<Record<string, unknown>> { |
| 54 | + const { p, find } = makeProtocol(); |
| 55 | + await p.findData({ object: 'invoice', query } as never); |
| 56 | + expect(find, `${JSON.stringify(query)} never reached engine.find`).toHaveBeenCalledTimes(1); |
| 57 | + return (find.mock.calls[0] as unknown[])[1] as Record<string, unknown>; |
| 58 | +} |
| 59 | + |
| 60 | +/** The refusal a rejected query produced, plus proof the engine was not reached. */ |
| 61 | +async function refusalFor(query: Record<string, unknown>): Promise<{ |
| 62 | + message: string; status?: number; code?: string; param?: string; |
| 63 | +}> { |
| 64 | + const { p, find } = makeProtocol(); |
| 65 | + let answered: unknown; |
| 66 | + try { |
| 67 | + answered = await p.findData({ object: 'invoice', query } as never); |
| 68 | + } catch (e) { |
| 69 | + const err = e as Error & { status?: number; code?: string; param?: string }; |
| 70 | + expect(find, 'the engine was reached before the refusal').not.toHaveBeenCalled(); |
| 71 | + return { message: err.message, status: err.status, code: err.code, param: err.param }; |
| 72 | + } |
| 73 | + throw new Error( |
| 74 | + `${JSON.stringify(query)} was ACCEPTED (answered ${JSON.stringify(answered)}) instead of refused`, |
| 75 | + ); |
| 76 | +} |
| 77 | + |
| 78 | +// --------------------------------------------------------------------------- |
| 79 | +// 1. REFUSAL |
| 80 | +// --------------------------------------------------------------------------- |
| 81 | + |
| 82 | +describe('#25229 — a malformed page window is refused, naming the spelling the caller wrote', () => { |
| 83 | + it.each<[string, Record<string, unknown>, string]>([ |
| 84 | + // [wire spelling, the query, what it used to become] |
| 85 | + ['$skip', { $skip: '-5' }, 'a knex throw, answered 500'], |
| 86 | + ['$skip', { $skip: '-1', $top: '3' }, 'a knex throw, answered 500'], |
| 87 | + ['$skip', { $skip: 'abc' }, 'offset: NaN'], |
| 88 | + ['skip', { skip: '-1' }, 'a knex throw, answered 500'], |
| 89 | + ['offset', { offset: -5 }, 'a knex throw, answered 500'], |
| 90 | + ['offset', { offset: 1.5 }, 'a fractional offset'], |
| 91 | + ['offset', { offset: '' }, 'offset: 0'], |
| 92 | + ['$top', { $top: 'abc' }, 'limit: NaN, every row'], |
| 93 | + ['$top', { $top: '-1' }, 'a dropped window, every row'], |
| 94 | + ['$top', { $top: '' }, 'limit: 0, an empty page'], |
| 95 | + ['$top', { $top: ' ' }, 'limit: 0, an empty page'], |
| 96 | + ['$top', { $top: '1.5' }, 'a fractional limit'], |
| 97 | + ['top', { top: 'abc' }, 'limit: NaN, every row'], |
| 98 | + ['limit', { limit: -1 }, 'a dropped window, every row'], |
| 99 | + ['limit', { limit: 'ten' }, 'limit: NaN, every row'], |
| 100 | + ['limit', { limit: true }, 'limit: 1'], |
| 101 | + ['limit', { limit: Number.POSITIVE_INFINITY }, 'an unbounded limit'], |
| 102 | + ])('refuses %s in %j with 400 INVALID_REQUEST (was: %s)', async (param, query) => { |
| 103 | + const err = await refusalFor(query); |
| 104 | + |
| 105 | + expect(err.code).toBe('INVALID_REQUEST'); |
| 106 | + expect(err.status).toBe(400); |
| 107 | + expect(err.param).toBe(param); |
| 108 | + expect(err.message).toContain(`Query parameter '${param}' must be a non-negative integer`); |
| 109 | + }); |
| 110 | + |
| 111 | + it('quotes the value it refused, bounded', async () => { |
| 112 | + const err = await refusalFor({ $top: 'x'.repeat(500) }); |
| 113 | + |
| 114 | + expect(err.message).toContain('got "xxxx'); |
| 115 | + expect(err.message.length).toBeLessThan(400); |
| 116 | + }); |
| 117 | +}); |
| 118 | + |
| 119 | +// --------------------------------------------------------------------------- |
| 120 | +// 2. PRESERVATION |
| 121 | +// --------------------------------------------------------------------------- |
| 122 | + |
| 123 | +describe('#25229 — a well-formed page window reaches the engine unchanged', () => { |
| 124 | + it.each<[Record<string, unknown>, number | undefined, number | undefined]>([ |
| 125 | + [{ $top: '5', $skip: '2' }, 5, 2], |
| 126 | + [{ top: '5', skip: '2' }, 5, 2], |
| 127 | + [{ limit: 5, offset: 2 }, 5, 2], |
| 128 | + [{ limit: '5', offset: '2' }, 5, 2], |
| 129 | + [{ $top: '0' }, 0, undefined], |
| 130 | + [{ $skip: '0' }, undefined, 0], |
| 131 | + [{}, undefined, undefined], |
| 132 | + ])('%j → limit %s, offset %s', async (query, limit, offset) => { |
| 133 | + const options = await optionsFor(query); |
| 134 | + |
| 135 | + expect(options.limit).toBe(limit); |
| 136 | + expect(options.offset).toBe(offset); |
| 137 | + // No window spelling leaks into the implicit-filter bucket. |
| 138 | + expect(options.where).toBeUndefined(); |
| 139 | + }); |
| 140 | +}); |
0 commit comments