|
| 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