Skip to content

Commit b20742d

Browse files
committed
fix(metadata-protocol): check query-parameter arity in findData's list-query normalizer (#7321)
`IHttpRequest.query` is `Record< string, string | string[] >` and the array arm is produced by a real first-party adapter, but every coercion in the shared list-query normalizer was written for the string arm. `Number(['1','2'])` is `NaN`, so `?$top=1&$top=2` reached the driver as `limit: NaN`; the same survey found the shape again on `$skip`/`offset`, `$search`, `$count`, the POST body's `object` copy, `having`, and the leftover-key bucket (`?status=open&status=won` lowered to a bare-array field spec that matches no row on any backend). Refused with 400 / INVALID_REQUEST — the code this normalizer already answers for the identical condition reached the other way (#4181 / #3795). A one-element array is one occurrence and is unwrapped; an empty array is no occurrence. The judgement is per parameter, not a sweep: `fields`/`select`/`$select`, `expand`/`populate`/`$expand`, `searchFields`, `orderBy`/`sort`/`$orderby`, `where`/`filter`/`filters`/`$filter` (whose array arm is a FILTER AST), `groupBy`, `aggregations` and the retired array keys all keep their array arm. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
1 parent f188ed6 commit b20742d

3 files changed

Lines changed: 541 additions & 9 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
`findData`'s shared list-query normalizer now checks the ARITY of every query
6+
parameter it reads, instead of coercing a repeated one blind (#7321).
7+
8+
`IHttpRequest.query` is `Record< string, string | string[] >` and the array arm
9+
is produced by a real first-party adapter (`NodeHttpServer` hands `?x=1&x=2`
10+
through as `['1','2']`). Every coercion in this normalizer was written for the
11+
string arm, so a repeated parameter was coerced into a value nobody asked for
12+
and served under a 200:
13+
14+
- `?$top=1&$top=2``Number(['1','2'])` is `NaN` → the driver was called with
15+
`limit: NaN`. Same for `$skip` / `offset`.
16+
- `?status=open&status=won` → the leftover-key bucket lowered it to
17+
`where: { status: ['open','won'] }`, and a bare array is not a valid field
18+
spec — it matches no row on any backend. An empty page, 200 OK.
19+
- `?$search=a&$search=b`, `?$count=true&$count=false` and a repeated body
20+
`object` behaved the same way, each in its own flavour.
21+
22+
Those are now refused with `400` / `error.code: INVALID_REQUEST` — the code this
23+
same normalizer already answers for the identical condition reached the other
24+
way (two SPELLINGS of one slot given different values, #4181#3795). A
25+
one-element array is one occurrence and is unwrapped, not refused; an empty
26+
array is no occurrence.
27+
28+
**Unchanged on purpose — this is a per-parameter judgement, not a sweep.**
29+
`$select` / `select` / `fields`, `$expand` / `populate` / `expand`,
30+
`$searchFields`, `$orderby` / `sort` / `orderBy`, `$filter` / `filter` /
31+
`filters` / `where` (whose array arm is a FILTER AST, not a repetition),
32+
`groupBy` and `aggregations` all accept the array arm on purpose and keep it
33+
byte for byte. A blanket "reject repeated parameters" rule would have broken
34+
every one of them.
35+
36+
Not reachable on today's production Hono adapter, which collapses repeated
37+
parameters to the first value before any handler runs; it becomes reachable when
38+
that collapse is removed (#6878 route 2).
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7321 — `findData`'s list-query normalizer coerces repeated query parameters
5+
* without checking arity.
6+
*
7+
* `IHttpRequest.query` is `Record< string, string | string[] >`
8+
* (`packages/spec/src/contracts/http-server.ts`) and the array arm is produced
9+
* by a real first-party adapter: `NodeHttpServer` hands `?x=1&x=2` through as
10+
* `['1','2']`, measured over a socket on #6878. Every coercion in this
11+
* normalizer was written for the string arm, so the array arm was coerced
12+
* blind — `Number(['1','2'])` is `NaN`, and `?$top=1&$top=2` reached the driver
13+
* as `limit: NaN`. That is the one MEASURED line the card was filed on; the
14+
* work is the survey around it, and the survey found the same shape on the
15+
* leftover-key bucket (`?status=open&status=won` lowers to
16+
* `where: {status: ['open','won']}`, which `matches-filter.ts` answers with a
17+
* bare `if (Array.isArray(spec)) return false` — an empty page under a 200).
18+
*
19+
* ## Three assertion classes, labelled, because only one of them is evidence
20+
*
21+
* 1. **REFUSAL** — a repeated single-valued parameter answers `400`
22+
* `INVALID_REQUEST` and the engine is never reached. Both `code` AND
23+
* `status` are asserted on every one of these: a bare `toThrow()` here
24+
* would be a permanently-green test for half the cases, because the
25+
* unfixed normalizer ALSO throws for some of them (a repeated `?filter=`
26+
* hits `malformedFilterArrayError` — a true refusal with a false
27+
* diagnosis), and would be blind for the other half, where the unfixed
28+
* normalizer answers 200 with the wrong rows.
29+
*
30+
* 2. **PRESERVATION of the legitimately-multi parameters** — `$select`,
31+
* `$expand`, `$searchFields`, `$orderby` and `$filter`'s AST array accept
32+
* the array arm ON PURPOSE. These assertions are GREEN IN BOTH DIRECTIONS
33+
* against the fix (they pass on `origin/main` unchanged), so on their own
34+
* they are GUARDS, not evidence. What makes them evidence is the VARIANT
35+
* measured on the PR: emptying `ARRAY_VALUED_QUERY_SLOTS` — i.e. replacing
36+
* the per-parameter disposition with a blanket "no parameter may repeat" —
37+
* turns this whole block red while block 1 stays green. That is the
38+
* damage case the card was filed to prevent, and it is what makes the
39+
* disposition table NECESSARY rather than merely sufficient.
40+
*
41+
* 3. **PRESERVATION of the ordinary single-valued request** — one occurrence,
42+
* as a bare string, is untouched. Also green in both directions, also a
43+
* guard: it is what a refusal is cheapest to break.
44+
*/
45+
46+
import { describe, it, expect, vi } from 'vitest';
47+
import { ObjectStackProtocolImplementation } from './protocol.js';
48+
49+
const SCHEMA = {
50+
name: 'invoice',
51+
nameField: 'name',
52+
searchableFields: ['name', 'status'],
53+
fields: {
54+
name: { name: 'name', type: 'text' },
55+
status: { name: 'status', type: 'text' },
56+
amount: { name: 'amount', type: 'number' },
57+
owner_id: { name: 'owner_id', type: 'lookup', reference: 'sys_user' },
58+
account_id: { name: 'account_id', type: 'lookup', reference: 'account' },
59+
},
60+
};
61+
62+
function makeProtocol() {
63+
const find = vi.fn(async () => [] as unknown[]);
64+
const aggregate = vi.fn(async () => [] as unknown[]);
65+
const engine = {
66+
registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) },
67+
find,
68+
aggregate,
69+
count: vi.fn(async () => 0),
70+
};
71+
return { p: new ObjectStackProtocolImplementation(engine as any), find, aggregate };
72+
}
73+
74+
/** The option bag `engine.find` was actually handed, for an accepted query. */
75+
async function optionsFor(query: Record<string, unknown>): Promise<Record<string, unknown>> {
76+
const { p, find } = makeProtocol();
77+
await p.findData({ object: 'invoice', query } as never);
78+
expect(find, `${JSON.stringify(query)} never reached engine.find`).toHaveBeenCalledTimes(1);
79+
return (find.mock.calls[0] as unknown[])[1] as Record<string, unknown>;
80+
}
81+
82+
/** The refusal a rejected query produced, plus proof the engine was not reached. */
83+
async function refusalFor(query: Record<string, unknown>): Promise<{
84+
message: string; status?: number; code?: string; param?: string;
85+
}> {
86+
const { p, find, aggregate } = makeProtocol();
87+
let answered: unknown;
88+
try {
89+
answered = await p.findData({ object: 'invoice', query } as never);
90+
} catch (e) {
91+
const err = e as Error & { status?: number; code?: string; param?: string };
92+
expect(find, 'the engine was reached before the refusal').not.toHaveBeenCalled();
93+
expect(aggregate, 'the engine was reached before the refusal').not.toHaveBeenCalled();
94+
return { message: err.message, status: err.status, code: err.code, param: err.param };
95+
}
96+
throw new Error(
97+
`${JSON.stringify(query)} was ACCEPTED (answered ${JSON.stringify(answered)}) instead of refused`,
98+
);
99+
}
100+
101+
// ---------------------------------------------------------------------------
102+
// 1. REFUSAL — the parameters whose declared type is a scalar
103+
// ---------------------------------------------------------------------------
104+
105+
describe('#7321 — a repeated single-valued parameter is refused, not coerced', () => {
106+
it.each<[string, Record<string, unknown>, string]>([
107+
// [wire spelling the caller wrote, the query, what it used to become]
108+
['$top', { $top: ['1', '2'] }, 'limit: NaN'],
109+
['top', { top: ['1', '2'] }, 'limit: NaN'],
110+
['limit', { limit: ['1', '2'] }, 'limit: NaN'],
111+
['$skip', { $skip: ['10', '20'] }, 'offset: NaN'],
112+
['skip', { skip: ['10', '20'] }, 'offset: NaN'],
113+
['offset', { offset: ['10', '20'] }, 'offset: NaN'],
114+
['$search', { $search: ['a', 'b'] }, 'a two-element search term'],
115+
['search', { search: ['a', 'b'] }, 'a two-element search term'],
116+
['$count', { $count: ['true', 'false'] }, 'neither true nor false'],
117+
['count', { count: ['true', 'false'] }, 'neither true nor false'],
118+
['object', { object: ['invoice', 'account'] }, 'a bogus object mismatch'],
119+
['having', { having: [{ a: 1 }, { b: 2 }], groupBy: ['status'] }, 'AST junk on aggregate'],
120+
])('refuses a repeated %s with 400 INVALID_REQUEST (was: %s)', async (param, query) => {
121+
const err = await refusalFor(query);
122+
123+
// The ADR-0112 envelope, not merely the throw: `code` AND `status`.
124+
expect(err.code).toBe('INVALID_REQUEST');
125+
expect(err.status).toBe(400);
126+
// #4226 discipline — the message names the spelling the caller WROTE,
127+
// not the canonical key the fold would have rewritten it to.
128+
expect(err.param).toBe(param);
129+
expect(err.message).toContain(`'${param}' query parameter was supplied 2 times`);
130+
});
131+
132+
it('refuses TWO IDENTICAL values too — the rule counts occurrences, not distinct values', async () => {
133+
// "At most one DISTINCT value" would be a de-duplication rule no caller
134+
// can predict; "supply it at most once" is checkable client-side
135+
// (#6877). `?$count=true&$count=true` is still two occurrences.
136+
const err = await refusalFor({ $count: ['true', 'true'] });
137+
138+
expect(err.code).toBe('INVALID_REQUEST');
139+
expect(err.status).toBe(400);
140+
expect(err.message).toContain('supplied 2 times');
141+
});
142+
143+
it('reports the real count, not just "more than one"', async () => {
144+
const err = await refusalFor({ $top: ['1', '2', '3', '4'] });
145+
146+
expect(err.message).toContain('supplied 4 times');
147+
});
148+
149+
it('refuses a repeated LEFTOVER key — the implicit field-filter bucket', async () => {
150+
// `?status=open&status=won` lowered to `where: {status: ['open','won']}`.
151+
// A bare array is not a valid field spec (`{ $in: [...] }` is), so
152+
// `matches-filter.ts` answers `false` for every row: an empty page under
153+
// a 200, which is the #4134 failure exactly.
154+
const err = await refusalFor({ status: ['open', 'won'] });
155+
156+
expect(err.code).toBe('INVALID_REQUEST');
157+
expect(err.status).toBe(400);
158+
expect(err.param).toBe('status');
159+
});
160+
161+
it('refuses the repeated parameter BEFORE the alias fold mis-diagnoses it', async () => {
162+
// `?top=1&top=2&limit=1` reaches the #3795 fold as `['1','2']` vs `'1'`,
163+
// which `JSON.stringify` calls two different values for one slot — a
164+
// true refusal (`Conflicting query parameters`) with a false diagnosis.
165+
// Arity runs first, so the caller is told what is actually wrong.
166+
const err = await refusalFor({ top: ['1', '2'], limit: '1' });
167+
168+
expect(err.message).toContain("'top' query parameter was supplied 2 times");
169+
expect(err.message).not.toContain('Conflicting query parameters');
170+
});
171+
});
172+
173+
// ---------------------------------------------------------------------------
174+
// 2. PRESERVATION — GUARDS. Green in both directions; see the variant file.
175+
// ---------------------------------------------------------------------------
176+
177+
describe('#7321 [GUARD — green in both directions] the legitimately-multi parameters keep their array arm', () => {
178+
it('$select repeated IS the projection list', async () => {
179+
const options = await optionsFor({ $select: ['name', 'status'] });
180+
181+
expect(options.fields).toEqual(['name', 'status']);
182+
});
183+
184+
it('select / fields repeated are the same projection under their other spellings', async () => {
185+
expect((await optionsFor({ select: ['name', 'status'] })).fields).toEqual(['name', 'status']);
186+
expect((await optionsFor({ fields: ['name', 'status'] })).fields).toEqual(['name', 'status']);
187+
});
188+
189+
it('$expand repeated IS the relation list', async () => {
190+
const options = await optionsFor({ $expand: ['owner_id', 'account_id'] });
191+
192+
expect(options.expand).toEqual({
193+
owner_id: { object: 'owner_id' },
194+
account_id: { object: 'account_id' },
195+
});
196+
});
197+
198+
it('$searchFields repeated IS the narrowed search set', async () => {
199+
const options = await optionsFor({ $search: 'acme', $searchFields: ['name', 'status'] });
200+
201+
expect(options.searchFields).toEqual(['name', 'status']);
202+
});
203+
204+
it('$orderby repeated COMPOSES into a multi-key sort', async () => {
205+
// Repetition on a list-valued slot concatenates; it does not conflict.
206+
// `normalizeSortNodes` has had an explicit `string[]` arm since #4226.
207+
const options = await optionsFor({ $orderby: ['name', '-amount'] });
208+
209+
expect(options.orderBy).toEqual([
210+
{ field: 'name', order: 'asc' },
211+
{ field: 'amount', order: 'desc' },
212+
]);
213+
});
214+
215+
it('a filter AST stays readable — `where`\'s array arm is a FILTER, not a repetition', async () => {
216+
// The single most expensive thing a blanket arity rule would break:
217+
// `['status','=','open']` is a three-element array that IS one filter.
218+
const options = await optionsFor({ $filter: ['status', '=', 'open'] });
219+
220+
expect(options.where).toEqual({ status: 'open' });
221+
});
222+
223+
it.each<[string, Record<string, unknown>]>([
224+
// Every WIRE spelling that folds into an array-valued slot, with a
225+
// two-element value that is otherwise valid for that slot. The source's
226+
// set is DERIVED from the same alias tables the fold uses, so a new
227+
// alias inherits its array arm automatically — this case is the
228+
// behavioural half of that derivation, and a new alias belongs here too.
229+
['$select', { $select: ['name', 'status'] }],
230+
['select', { select: ['name', 'status'] }],
231+
['fields', { fields: ['name', 'status'] }],
232+
['$orderby', { $orderby: ['name', '-amount'] }],
233+
['sort', { sort: ['name', '-amount'] }],
234+
['orderBy', { orderBy: ['name', '-amount'] }],
235+
['$expand', { $expand: ['owner_id', 'account_id'] }],
236+
['populate', { populate: ['owner_id', 'account_id'] }],
237+
['expand', { expand: ['owner_id', 'account_id'] }],
238+
['$searchFields', { $search: 'acme', $searchFields: ['name', 'status'] }],
239+
['searchFields', { search: 'acme', searchFields: ['name', 'status'] }],
240+
['$filter', { $filter: ['status', '=', 'open'] }],
241+
['filter', { filter: ['status', '=', 'open'] }],
242+
['filters', { filters: ['status', '=', 'open'] }],
243+
['where', { where: ['status', '=', 'open'] }],
244+
['groupBy', { groupBy: ['status', 'name'] }],
245+
['aggregations', { aggregations: [
246+
{ function: 'sum', field: 'amount', alias: 'total' },
247+
{ function: 'count', field: 'amount', alias: 'n' },
248+
] }],
249+
])('%s accepts a two-element array without a 400', async (_param, query) => {
250+
// Asserted as "not refused" rather than "reached engine.find", because
251+
// `groupBy` / `aggregations` legitimately route to `engine.aggregate`.
252+
const { p } = makeProtocol();
253+
254+
await expect(p.findData({ object: 'invoice', query } as never)).resolves.toBeDefined();
255+
});
256+
257+
it('groupBy / aggregations keep their array arms', async () => {
258+
const { p, aggregate } = makeProtocol();
259+
await p.findData({
260+
object: 'invoice',
261+
query: { groupBy: ['status'], aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }] },
262+
} as never);
263+
264+
expect(aggregate).toHaveBeenCalledTimes(1);
265+
const opts = (aggregate.mock.calls[0] as unknown[])[1] as Record<string, unknown>;
266+
expect(opts.groupBy).toEqual(['status']);
267+
});
268+
});
269+
270+
describe('#7321 [GUARD — green in both directions] an ordinary single-valued request is untouched', () => {
271+
it('?$top=5&$skip=10 still normalizes to limit/offset numbers', async () => {
272+
const options = await optionsFor({ $top: '5', $skip: '10' });
273+
274+
expect(options.limit).toBe(5);
275+
expect(options.offset).toBe(10);
276+
});
277+
278+
it('a single leftover key is still an implicit equality predicate', async () => {
279+
expect((await optionsFor({ status: 'open' })).where).toEqual({ status: 'open' });
280+
});
281+
282+
it('a comma-list projection is still split, not treated as multi-valued', async () => {
283+
expect((await optionsFor({ $select: 'name,status' })).fields).toEqual(['name', 'status']);
284+
});
285+
});
286+
287+
// ---------------------------------------------------------------------------
288+
// 3. The one-occurrence array arm — an adapter's encoding, not a repetition
289+
// ---------------------------------------------------------------------------
290+
291+
describe('#7321 — a ONE-element array is one occurrence, unwrapped rather than refused', () => {
292+
it('unwraps a leftover key, which used to match nothing at all', async () => {
293+
// The signal case. `{status: ['open']}` is a bare array field spec, so
294+
// `matches-filter.ts` answered `false` for every row: the query looked
295+
// served and returned an empty page.
296+
expect((await optionsFor({ status: ['open'] })).where).toEqual({ status: 'open' });
297+
});
298+
299+
it('[GUARD] unwraps a one-element window, which `Number()` already got right', async () => {
300+
// Green in both directions on purpose: `Number(['5'])` is 5, because a
301+
// one-element array stringifies to its element. Pinned so the unwrap
302+
// cannot silently start producing something else.
303+
expect((await optionsFor({ $top: ['5'] })).limit).toBe(5);
304+
});
305+
306+
it('treats an EMPTY array as not supplied, rather than as a value', async () => {
307+
// `Number([])` is 0, so an empty `limit` used to become `limit: 0`; and
308+
// a key left behind carrying `undefined` would be lowered into an
309+
// implicit `{status: undefined}` predicate by the leftover bucket.
310+
const options = await optionsFor({ limit: [], status: [] });
311+
312+
expect(options).not.toHaveProperty('limit');
313+
expect(options).not.toHaveProperty('status');
314+
expect(options.where).toBeUndefined();
315+
});
316+
});

0 commit comments

Comments
 (0)