Skip to content

Commit d659c1d

Browse files
fix(rest)!: GET /meta/app refuses a bracketed id key (?id[]=, ?id[0]=) instead of answering every app (#29046)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X7czLawmpLmMTMhtzDM1pmZ)_ Fixes #28930 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no skill or doc page teaches the app list's `?id=` filter (`git grep` over `skills/` and `content/docs/` finds none), so nothing there names a spelling this refuses. ## 维护者速读 - **改了什么**:应用列表 `GET /meta/app` 收到括号写法的 `id`(`?id[]=NAME`、`?id[0]=NAME`,以及任何以 `id[` 开头的键)时,答 400 `VALIDATION_ERROR` 并点名该键,不再忽略过滤、把调用者能看的整份应用列表当作结果返回。 - **为什么改**:按 qs 风格序列化数组的客户端只想取一个应用,却拿回全部应用,还会把第一个当作它要的那个来渲染;这和 #28548 修好的空 `?id=` 是同一类静默放宽。 - **风险与回滚**:只动 `packages/rest` 列表链里的一步(两条传输共用),`patch` 带 BREAKING 横幅;仅影响发送括号键的 API 调用方,`@objectstack/client` 与控制台都不发 `id`。回滚即还原本 PR。 - **先看**:`packages/rest/src/meta-item-read-gate.ts`(`refuseBracketedAppIdFilter` 与第 3 步的调用);`packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts` 末尾新块;`.changeset/28930-bracketed-app-id-refused.md`。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/meta-app-publish-gate.test.ts`,预期 31 个全绿,其中三条 `BRACKETED` 用例断言 400。 - **需要你做的**:无,席位落地。 ## What changes `?id[]=setup` and `?id[0]=setup` reach the `/meta` list chain under the key as spelled: every adapter hands query keys through unparsed (`readQuery` in `plugin-hono-server`, `readDispatchQuery` in `@objectstack/hono`). Step 3 of `createMetaListAnswer`, the one-app filter, reads `query.id` only, so it never saw them, and the caller's whole visible list came back with 200. - `packages/rest/src/meta-item-read-gate.ts`: step 3 now asks `refuseBracketedAppIdFilter(query)` first on the app list. Any query key that opens `id[` is refused with `400 VALIDATION_ERROR` and no list. The refusal uses the empty `?id=` refusal's sentence frame (#28548). Both now go through one builder, `throwListFilterRefusal`, and the empty `?id=` / `?object=` messages stay byte for byte the same: - empty (unchanged): `Query parameter 'id' names no app; got "". The list was not served: an empty app name is not a filter. Name one app, or leave the parameter out to list every app you can see.` - bracketed (new): `Query parameter 'id[]' names no app; the 'id' filter takes exactly one app name, spelled ?id=NAME. The list was not served: a bracketed 'id' key is not a filter. Name one app, or leave the parameter out to list every app you can see.` - The refusal sits after the per-caller gate (step 2), where #28548 placed the empty refusal, and it throws to each transport's own catch. So `RestServer` and the runtime dispatcher answer it identically, through the one seam both list doors share. - A bracketed key next to a plain `?id=` is refused too, because the request carries two spellings of one filter. **Measured: does the seam serve other `/meta/:type` list doors?** Only the app list reads `id`. Step 3 is keyed on the folded `metaType === 'app'`, so `/meta/apps` is covered and no other type reads an `id` spelling. On every other type a bracketed `id` key stays unread, the way `?id=` is (pinned by a control). The query parse is the adapters' and is not this chain's to change. So the fix is `/meta/app` only, and the siblings are reported under Acceptance notes. **On the order's words "the same 400 the repeated `id` gets (#28548's words)".** The repeated `id` answer is `repeatedQueryParamMessage`, which says `supplied N times`. That would be false for a key supplied once. So the bracketed key gets #28548's own refusal frame, as the claim's "extends #28548's refusal" reads. Status and code are the same as the repeated `id`'s 400. ## Tests Measured at HEAD `2055ce23` (worktree on `claude/project-thread-vh6ns8`, base `135054eb`). - `packages/rest/src/meta-app-publish-gate.test.ts`: `it.each(['id[]', 'id[0]', 'id[name]'])` asserts `400` + `code: VALIDATION_ERROR` + the message's first clause naming the key, with no list in the body. It runs for three caller postures, on the plural `apps`, and with a bracketed key next to a plain `id`. A control shows a bracketed `id` key on the view list is not read. - `packages/runtime/src/domains/meta-list-projection-parity.test.ts`: two `PARAM_PROBES` (`?id[]=crm`, `?id[0]=crm`) drive both transports over every type and caller. The census of row A grows by 2 probes × 26 type cells (991 tests). The "reference moves" test asserts the `RestServer` answer is a 400. - `packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts` (booted showcase over the Hono stack): `?id[]=setup` and `?id[0]=setup` as the admin and as a signed-up member (the `client_liaison` holder) answer `400 VALIDATION_ERROR` with `Query parameter 'id[]' names no app` and no items. Controls: `?id=showcase_app` returns one app, `?id=setup` returns `[]`, `?id=` returns 400, a repeated `id` returns 400, and no `id` returns the list, where a member never sees `setup`. <details><summary>Red first / ablation: dropping the arm reds only the new cases</summary> The mutation replaced the call `if (metaType === 'app') refuseBracketedAppIdFilter(query);` with `void refuseBracketedAppIdFilter;` through `scripts/ablation-replace.mjs`, which is main's behaviour. The tool reported anchor 1 → 0, blob `9c8674a8` → `b9866803`. Then `pnpm --filter @objectstack/rest build` (exit 0), and `ablation-dist-preflight.mjs @objectstack/rest "refuseBracketedAppIdFilter(query);" --absent` (exit 0, marker absent from all 6 built files). - rest unit: `Tests 3 failed | 28 passed (31)`. The three failures are exactly the `BRACKETED id[]` / `id[0]` / `id[name]` cases. - runtime parity: `Tests 1 failed | 990 passed (991)`. The one failure is the "reference moves" test. Row A stays green because both transports gave the same unfiltered list. - dogfood: `Tests 2 failed | 12 passed (14)`. The failures are `?id[]=setup` and `?id[0]=setup`, which got `status: 200` with `items: [showcase_app, …]`, the card's reported answer. Restore: `ablation-replace --restore` reported blob == HEAD `9c8674a8` and an empty `git diff HEAD`. The rebuild exited 0, and the preflight showed the marker present in 2 built files with a clean tree. A first ablation attempt deleted the line outright. The now-unused function then failed the rest DTS build (exit 1), and that run was discarded. </details> <details><summary>Package suites and typecheck (HEAD 2055ce2)</summary> - `pnpm --filter @objectstack/rest test`: exit 0, `Test Files 366 passed (366)`, `Tests 6940 passed | 332 skipped (7272)` - `pnpm --filter @objectstack/rest typecheck`: exit 0 - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/meta-list-projection-parity.test.ts`: exit 0, `Tests 991 passed (991)` - `pnpm --filter @objectstack/runtime typecheck`: exit 0 - `pnpm --filter @objectstack/dogfood exec vitest run test/meta-read-doors-live.dogfood.test.ts`: exit 0, `Tests 14 passed (14)` </details> ## Acceptance notes - **Premise.** The card and the order place the filter in `packages/rest/src/rest-server.ts`. It lives in `packages/rest/src/meta-item-read-gate.ts` (`createMetaListAnswer`, step 3), the chain both transports call. The defect reproduced as stated (see the ablation leg). - **The dogfood stack has no `setup` app.** `bootShowcase` serves only `showcase_app` to both callers. The card's `setup` comes from a full `os dev` boot. The bracketed probe still uses `setup` as the order asks, and on main it answered the whole list (`[showcase_app]`). The narrowing control uses `?id=showcase_app`, and `?id=setup` → `[]`. - **Sibling, same class (finding, not fixed here).** `GET /meta/view?object[]=lead` answers every view with 200, where `?object=lead` answers one. This was measured through `RestServer`'s `GET /meta/:type` route handler with a three-view fixture: `object[]` gave `200 ["all_leads","acct_view","loose_view"]` and `object=` gave `200 ["all_leads"]`. The adapters pass the key through the same way, so the view switcher's filter is dropped the same way. Not measured: `package[]` on the list doors (`?package=` scopes the read, and an unread key would read as "every package"). - **Overlap.** Open PR #28850 also edits `meta-read-doors-live.dogfood.test.ts`, in its header comment and the acceptance[2] setup. This PR only appends a `describe` block at the end of the file, so the hunks do not meet. No other open PR touches `meta-item-read-gate.ts`, `rest-server.ts`, the rest unit file or the runtime parity file. Every open PR was checked by diff names; #28731 was checked through its file list. ## Verification All local readings are at HEAD `2055ce23`. Every family CI runs is cited from this PR's check-runs in the report, and none of them was re-run here. - **Derived gate set** (`node scripts/pm/dispatch-gates.mjs --commands`, from the merge-base): 64 commands run, all exit 0. `--ran` reconciliation answers `64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED`. Two first-pass exit 3s were prerequisites, not reds, and each was re-run green: - `check-plugin-teardown-shape.mjs --self-test` needed its pinned commit on this shallow clone (`git fetch --depth=1 origin 621a487…`). - `check:dual-build-cjs-loads` read 7 packages with no `dist/`. After they were built, it reported `115 published require entry point(s) across 70 package(s) load`. The record is `pm-orders/server/28930-gates-ran.txt` in the project files. - **Full dogfood battery** (the `(narrowing)` arm owes it): `pnpm --filter @objectstack/dogfood test` under the verify lock, after the `@objectstack/dogfood^...` closure build. `VERDICT command-exit 0` · `Test Files 383 passed | 3 skipped (386)` · `Tests 3166 passed | 28 skipped (3194)`. - **Not run locally:** `test:repo` (the vitest `repo` projects, which belong to CI Test Core) and `pnpm lint` (a repo-wide scan owned by CI). The one `repo`-project file this diff edits, `packages/runtime/src/domains/meta-list-projection-parity.test.ts` (listed in `packages/runtime/vitest.repo-tests.json`), was run directly (991/991). The rest of that project is left to CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5o6bowfKzFDhW76JZ3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ada509 commit d659c1d

5 files changed

Lines changed: 153 additions & 5 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: a bracketed `id` key on the app list (`?id[]=NAME`, `?id[0]=NAME`) is refused 400 naming the key instead of being ignored and answered with the whole list
6+
7+
Clause-②: no (narrowing) — `GET /meta/app` / `GET /meta/apps` with any query key that opens `id[` (on `RestServer` and on the runtime dispatcher, which share the list chain) is refused with `400 VALIDATION_ERROR`; it answered 200 with the caller's whole visible list before. Nothing is widened: no export, answer member, error code or status is added.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a list request that spells its one-app filter as an array key is refused instead of served unfiltered. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing on the app list read (`@objectstack/rest`).
13+
14+
- **Before.** `?id[]=setup` and `?id[0]=setup` (the qs-style array spellings) reached the list chain under the key as spelled, which the one-app filter never read, so a caller who asked for one app got every app it may see.
15+
- **Now.** The list chain refuses a key that opens `id[` after the per-caller visibility gate: `400 VALIDATION_ERROR`, message `Query parameter 'id[]' names no app; the 'id' filter takes exactly one app name, spelled ?id=NAME. …` (the message names the key as sent, in the frame the empty `?id=` refusal uses), and no list in the body. A bracketed key beside a plain `?id=` is refused too.
16+
- **Unchanged.** `?id=NAME` narrows to that app (or an empty list when it names none); no `id` lists every app the caller may see; an empty `?id=` and a repeated `id` keep their 400s; a bracketed `id` key on any type but `app` is still ignored, as `?id=` is there.
17+
- **Who sees it.** An API client that serialises arrays qs-style. `@objectstack/client` and the console send no `id` on this read.
18+
19+
**The remedy.** FROM `?id[]=NAME` (or `?id[0]=NAME`) TO `?id=NAME`; leave `id` out to list every app.

‎packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,51 @@ describe('[#26572] the v18 /meta read doors, over HTTP on a booted showcase', ()
164164
expect((await sortability(admin)).budget?.sortable).toBe(true);
165165
});
166166
});
167+
168+
// A client that serialises arrays qs-style sends `?id[]=NAME` or
169+
// `?id[0]=NAME`. The adapter hands the key through as spelled (`id[]`), so
170+
// the app list's one-app filter never read it and the caller's whole list
171+
// came back for a request that asked for one app. It is refused like the
172+
// empty `?id=`: 400 VALIDATION_ERROR naming the key, no list.
173+
describe('GET /meta/app with a bracketed `id` key is refused, never answered with the whole list', () => {
174+
const appNames = (body: any): string[] => ((Array.isArray(body) ? body : body?.items) ?? []).map((a: any) => a?.name);
175+
176+
for (const spelling of ['id[]', 'id[0]']) {
177+
it(`?${spelling}=setup: 400 VALIDATION_ERROR naming the key, for the admin and for a member`, async () => {
178+
for (const [who, token] of [['admin', admin], ['member', liaison]] as const) {
179+
const res = await stack.apiAs(token, 'GET', `/meta/app?${spelling}=setup`);
180+
const body: any = await res.json();
181+
expect({ who, status: res.status, code: codeOf(body) }, JSON.stringify(body).slice(0, 400))
182+
.toEqual({ who, status: 400, code: 'VALIDATION_ERROR' });
183+
expect(messageOf(body)).toMatch(new RegExp(`^Query parameter '${spelling.replace(/[[\]]/g, '\\$&')}' names no app`));
184+
expect(appNames(body)).toEqual([]);
185+
}
186+
});
187+
}
188+
189+
it('CONTROLS — ?id=NAME is one app, ?id= and a repeated id are 400, no id is the pruned list', async () => {
190+
const one = await stack.apiAs(admin, 'GET', '/meta/app?id=showcase_app');
191+
expect(one.status).toBe(200);
192+
expect(appNames(await one.json())).toEqual(['showcase_app']);
193+
// The filter narrows: `setup` names no app on this stack, so `?id=setup`
194+
// is an empty list where `?id[]=setup` answered the whole one.
195+
const none = await stack.apiAs(admin, 'GET', '/meta/app?id=setup');
196+
expect(none.status).toBe(200);
197+
expect(appNames(await none.json())).toEqual([]);
198+
for (const query of ['id=', 'id=showcase_app&id=showcase_app']) {
199+
const res = await stack.apiAs(admin, 'GET', `/meta/app?${query}`);
200+
expect({ query, status: res.status, code: codeOf(await res.json()) })
201+
.toEqual({ query, status: 400, code: 'VALIDATION_ERROR' });
202+
}
203+
const adminList = await stack.apiAs(admin, 'GET', '/meta/app');
204+
const memberList = await stack.apiAs(liaison, 'GET', '/meta/app');
205+
expect(adminList.status).toBe(200);
206+
expect(memberList.status).toBe(200);
207+
const adminApps = appNames(await adminList.json());
208+
const memberApps = appNames(await memberList.json());
209+
expect(adminApps, JSON.stringify({ adminApps, memberApps })).toContain('showcase_app');
210+
expect(memberApps, JSON.stringify({ adminApps, memberApps })).toContain('showcase_app');
211+
expect(memberApps, 'a member never sees setup').not.toContain('setup');
212+
});
213+
});
167214
});

‎packages/rest/src/meta-app-publish-gate.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,42 @@ describe('#7566 — `GET /meta/app?id=` narrows the list instead of being droppe
338338
}
339339
});
340340

341+
// A client that serialises arrays qs-style sends `?id[]=crm` or
342+
// `?id[0]=crm`. The adapters hand the key through as spelled, so the
343+
// filter, which reads `query.id`, never saw it and the caller who asked for
344+
// one app got every app it may see. Refused with the empty `?id=`'s
345+
// answer, naming the key as sent; the dispatcher's twin is pinned in
346+
// `@objectstack/runtime`'s list parity census.
347+
it.each(['id[]', 'id[0]', 'id[name]'])('a BRACKETED `%s` key is refused with 400 naming it — never the whole list', async (key) => {
348+
for (const perms of [['studio.access'], ['manage_users'], []]) {
349+
const { rest } = setup(perms);
350+
const res = await getList(rest, 'app', { [key]: 'crm' });
351+
352+
expect(res.statusCode, `caller holding [${perms}]`).toBe(400);
353+
expect(res.body?.code).toBe('VALIDATION_ERROR');
354+
expect(String(res.body?.error).startsWith(`Query parameter '${key}' names no app`)).toBe(true);
355+
expect(res.body?.items).toBeUndefined();
356+
expect(Array.isArray(res.body)).toBe(false);
357+
}
358+
// The plural spelling refuses identically, and so does a bracketed key
359+
// beside a plain one: two spellings of one filter.
360+
for (const [type, query] of [['apps', { [key]: 'crm' }], ['app', { id: 'crm', [key]: 'account' }]] as const) {
361+
const res = await getList(setup(['manage_users']).rest, type, query);
362+
expect(res.statusCode, `${type} ${JSON.stringify(query)}`).toBe(400);
363+
expect(res.body?.code).toBe('VALIDATION_ERROR');
364+
}
365+
});
366+
367+
it('a bracketed `id` key on another list type is not read, as no other `id` spelling is (control)', async () => {
368+
const { rest, protocol } = setup(['manage_users']);
369+
protocol.getMetaItems = vi.fn(async ({ type: t }: any) =>
370+
String(t ?? '').startsWith('view') ? [{ name: 'all_leads', viewKind: 'list', object: 'lead' }] : []);
371+
const res = await getList(rest, 'view', { 'id[]': 'nope' });
372+
373+
expect(res.statusCode).toBe(200);
374+
expect(namesFrom(res.body)).toEqual(['all_leads']);
375+
});
376+
341377
it('a WHITESPACE id keeps its answer — a value no app is named, so an empty list (#28548 control)', async () => {
342378
const { rest } = setup(['manage_users']);
343379
const res = await getList(rest, 'app', { id: ' ' });

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3418,9 +3418,46 @@ export type MetaListAnswer =
34183418
* the dispatcher's `errorFromThrown`), as {@link refuseUnknownMetaListType} does.
34193419
*/
34203420
function refuseEmptyListFilter(param: 'id' | 'object', names: 'app' | 'object', lists: 'app' | 'view'): never {
3421+
throwListFilterRefusal(param, param, names, lists, 'got ""', `an empty ${names} name`);
3422+
}
3423+
3424+
/**
3425+
* The app list's answer to a BRACKETED spelling of its one-app filter —
3426+
* `?id[]=NAME`, `?id[0]=NAME`, any key that opens `id[`. A client that
3427+
* serialises arrays qs-style sends it; every adapter hands the key through as
3428+
* spelled (`readQuery` in `plugin-hono-server`, `readDispatchQuery` in
3429+
* `@objectstack/hono`), so step 3 of {@link createMetaListAnswer}, which reads
3430+
* `query.id`, never saw it, and the caller who asked for one app was answered
3431+
* every app it may see. Refused with {@link refuseEmptyListFilter}'s answer —
3432+
* one sentence frame, `400 VALIDATION_ERROR` — naming the key as sent: the
3433+
* filter takes exactly one name, spelled `?id=NAME`. A bracketed key beside a
3434+
* plain `?id=` is refused too: the request carries two spellings of one filter.
3435+
*
3436+
* Only the app list reads `id`, so only it judges the bracketed spelling; on
3437+
* every other type the key is not read, as no other `id` spelling is.
3438+
*/
3439+
function refuseBracketedAppIdFilter(query: Readonly<Record<string, unknown>> | undefined): void {
3440+
if (!query || typeof query !== 'object') return;
3441+
const key = Object.keys(query).find((k) => k.startsWith('id['));
3442+
if (key === undefined) return;
3443+
throwListFilterRefusal(
3444+
'id', key, 'app', 'app',
3445+
"the 'id' filter takes exactly one app name, spelled ?id=NAME", "a bracketed 'id' key",
3446+
);
3447+
}
3448+
3449+
/** The one sentence frame of the list chain's narrowing-filter refusals. */
3450+
function throwListFilterRefusal(
3451+
param: 'id' | 'object',
3452+
key: string,
3453+
names: 'app' | 'object',
3454+
lists: 'app' | 'view',
3455+
got: string,
3456+
notAFilter: string,
3457+
): never {
34213458
const err: any = new Error(
3422-
`Query parameter '${param}' names no ${names}; got "". The list was not served: an empty ${names} `
3423-
+ `name is not a filter. Name one ${names}, or leave the parameter out to list every ${lists} you can see.`,
3459+
`Query parameter '${key}' names no ${names}; ${got}. The list was not served: ${notAFilter} `
3460+
+ `is not a filter. Name one ${names}, or leave the parameter out to list every ${lists} you can see.`,
34243461
);
34253462
err.status = 400;
34263463
err.code = 'VALIDATION_ERROR';
@@ -3516,8 +3553,9 @@ async function describeMetaPositionList(sources: MetaListAnswerSources, list: an
35163553
* items, before the locale collapse (`_packageId` scopes membership).
35173554
* 3. `app` — `?id=<app>` narrows to the app of that `name` (#7566); absent
35183555
* means no filter, and [#28548] an EMPTY value is refused (`400
3519-
* VALIDATION_ERROR`), never read as absent; no match is an EMPTY list,
3520-
* never a 404.
3556+
* VALIDATION_ERROR`), never read as absent, as is a bracketed key
3557+
* (`?id[]=`, `?id[0]=`), never read as no filter; no match is an EMPTY
3558+
* list, never a 404.
35213559
* 4. `view` — `?object=<object>` keeps the independent views bound to that
35223560
* object, sorted by `order` then `name` (the switcher); [#28548] an EMPTY
35233561
* value is refused (`400 VALIDATION_ERROR`), never read as absent.
@@ -3575,7 +3613,9 @@ export function createMetaListAnswer(
35753613
}
35763614

35773615
// 3. [#7566] `?id=<app>`. [#28548] An EMPTY one is refused, never
3578-
// read as absent (see {@link refuseEmptyListFilter}).
3616+
// read as absent (see {@link refuseEmptyListFilter}), and so is a
3617+
// bracketed spelling of the key (see {@link refuseBracketedAppIdFilter}).
3618+
if (metaType === 'app') refuseBracketedAppIdFilter(query);
35793619
const appIdFilter = metaType === 'app' ? query?.id : undefined;
35803620
if (appIdFilter === '') refuseEmptyListFilter('id', 'app', 'app');
35813621
if (typeof appIdFilter === 'string') {

‎packages/runtime/src/domains/meta-list-projection-parity.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,10 @@ const PARAM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record
525525
// [#28548] An EMPTY app name is refused (400 VALIDATION_ERROR) on both
526526
// transports, never read as absent.
527527
{ label: '?id= (empty)', param: 'id', query: { id: '' } },
528+
// …and so is a BRACKETED `id` key (a qs-style array spelling), never
529+
// read as no filter.
530+
{ label: '?id[]=crm', param: 'id', query: { 'id[]': 'crm' } },
531+
{ label: '?id[0]=crm', param: 'id', query: { 'id[0]': 'crm' } },
528532
{ label: '?object=lead', param: 'object', query: { object: 'lead' } },
529533
{ label: '?object=nope', param: 'object', query: { object: 'nope' } },
530534
// [#28548] …and so is an EMPTY view object.
@@ -687,6 +691,8 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\
687691
expect((await read('/meta/app', { id: 'nope' })).items).toEqual([]);
688692
const blank = await read('/meta/app', { id: '' });
689693
expect({ status: blank.status, code: blank.code, items: blank.items }).toEqual({ status: 400, code: 'VALIDATION_ERROR', items: undefined });
694+
const bracketed = await read('/meta/app', { 'id[]': 'crm' });
695+
expect({ status: bracketed.status, code: bracketed.code, items: bracketed.items }).toEqual({ status: 400, code: 'VALIDATION_ERROR', items: undefined });
690696
expect(names((await read('/meta/view', { object: 'lead' })).items)).toEqual(['lead_mine', 'lead_all']);
691697
expect((await read('/meta/views', { object: 'nope' })).items).toEqual([]);
692698
const blankObject = await read('/meta/views', { object: '' });

0 commit comments

Comments
 (0)