Skip to content

Commit f3f7b62

Browse files
fix(rest)!: an empty ?id= on the app list or ?object= on the view list is refused 400 naming the parameter instead of read as absent (#28602)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XG3uopXrSJccJc9XqnMV8ty)_ Fixes #28548 Clause-②: no (narrowing) — `GET /meta/app` / `GET /meta/apps` with an empty `?id=`, and `GET /meta/view` / `GET /meta/views` with an empty `?object=` (on `RestServer` and on the runtime dispatcher, which hand their store answer to the same list chain), are refused with `400 VALIDATION_ERROR` naming the parameter; they answered 200 with the caller's whole visible list before. Nothing is widened: no export, answer member, error code or status is added. Metadata-protocol: none ## 维护者速读 - **改了什么**:`GET /api/v1/meta/app?id=`(id 为空)和 `GET /api/v1/meta/view?object=`(object 为空)现在回 400 `VALIDATION_ERROR`,消息点名参数,不再返回调用者能看到的全部应用或全部视图。 - **为什么改**:以前空 id 被当成“没写”,调用方本想取一个应用,名字却是空的,结果拿到整张应用列表,看起来像正常结果;视图列表的空 `object` 是同一个毛病,席位要求在本 PR 一并修。和 #27484(空 `$searchFields` 被当成没写)同一类,按同样方式拒收。 - **风险与回滚**:只影响发了空 `id` 或空 `object` 的 API 调用方;`@objectstack/client` 和 Console 读这两个列表都不带这两个参数。非空值、不带参数、重复参数的回答都不变。回滚 = revert 本 PR。 - **先看**:`packages/rest/src/meta-item-read-gate.ts`(`createMetaListAnswer` 第 3、4 步和 `refuseEmptyListFilter`)、`packages/rest/src/meta-app-publish-gate.test.ts`(有意翻转的钉子)。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/meta-app-publish-gate.test.ts`,预期 27 个用例全过(空 id 三种调用者都回 400,空格 id 仍回空列表,空 object 回 400)。 - **需要你做的**:无,席位落地(进队前欠一次契约复核)。 ## What changes - **`createMetaListAnswer` step 3** (`packages/rest/src/meta-item-read-gate.ts`): the app filter used to skip an empty `id` (`typeof appIdFilter === 'string' && appIdFilter !== ''`), so the request was served as if no filter had been sent. An empty value now goes to the new `refuseEmptyListFilter()`, which throws `400 VALIDATION_ERROR` with the message `Query parameter 'id' names no app; got "". …`. The throw reaches each transport's own error door (`RestServer`'s `handleRouteError`, the dispatcher's `errorFromThrown`), the way `refuseUnknownMetaListType` already answers on this route, so neither transport hand-builds a body. - **`createMetaListAnswer` step 4** (seat-directed fold, same class): `?object=` on `view` was read with a truthiness test (`query?.object`), so `GET /meta/view?object=` listed every view, unsorted and including views bound to no object, as if no filter had been sent. An empty value now goes to the same helper: `400 VALIDATION_ERROR`, message `Query parameter 'object' names no object; got "". …`. A named object still switches; `?object=` on other types is still ignored. - **Order kept.** Both refusals sit in steps 3 and 4, after the per-caller visibility gate (step 2), which keeps its place. The chain stays the one place a list step lives, so both transports answer the same. - **Unchanged.** A non-empty `id` narrows as before (one app, or an empty list when it names none, `' '` included). No `id` lists every app the caller may see. A repeated `id` is still the arity 400 at the door entry. `?id=` on any other type is still ignored. - **Docblock** of `createMetaListAnswer` steps 3 and 4 updated to say an empty value is refused. - **Pin flipped on purpose**: `meta-app-publish-gate.test.ts` "an ABSENT id still returns the whole published set" asserted that `?id=` (empty) returns the full list, citing an unset `select` as its producer. No producer sends it (see Acceptance notes), and the card's expected result rules the other way, so that half moves to the new refusal case. - **Changeset** `.changeset/28548-empty-app-id-refused.md`: `patch` on `@objectstack/rest`, BREAKING banner, ADR-0087 `not-required (no-migration-prescription)`. `@objectstack/runtime` changes only a test, which its `files[]` does not ship. ### Which precedent, and why The card offers two outcomes: refuse with a 400 naming the parameter, or match nothing as `' '` does. This follows the precedent PR #27670 set for #27484: that change refused a blank `$searchFields` with `400 VALIDATION_ERROR` and a message naming the parameter, rather than reading it as a value that matches nothing. Along the four axes: there is no real caller that sends an empty `id` (measured below), so the loud answer costs nobody; a refusal is the contract-first answer and the one that stops an AI-written client from mistaking a blank name for a real filter; and it adds no gate or surface, only a refusal on the existing door. ## Tests <details><summary>Red first, then green (head 9c45ac5)</summary> - `id` (first commit): red on the base code with the new tests — `meta-app-publish-gate.test.ts` "an EMPTY id is refused…" (`expected 200 to be 400`) and `meta-list-projection-parity.test.ts` row A "the reference moves" on the new `?id=` assertion. The row A matrix cells for `?id= (empty)` passed on the base, because both transports agreed on the wrong 200. - `object` (second commit): red on 9246901 with the new tests — "an EMPTY view `object` is refused…" (`view: expected 200 to be 400`, 1 failed / 26 passed) and row A on the new `?object=` assertion (1 failed / 934 passed). - Green on 9c45ac5 after a `rest` rebuild: `meta-app-publish-gate.test.ts` + `meta-view-dashboard-audience-gate.test.ts` 46/46; `meta-list-projection-parity.test.ts` + `dispatcher-query-arity.test.ts` 968/968. - New cases: an empty `id` as a builder (`studio.access`), as `manage_users` and as a caller holding nothing, on `/meta/app` and `/meta/apps`; an empty `object` on `/meta/view` and `/meta/views`. Each asserts `status` 400, `code` `VALIDATION_ERROR`, the message's first clause, and no items. The dispatcher's twin is pinned by the new `?id= (empty)` and `?object= (empty)` probes in `@objectstack/runtime`'s list parity census: every type × caller cell must answer the same `status` + `code` + items on both transports, and row A asserts `400 VALIDATION_ERROR` with no items for each. - Controls: `?id=crm` returns that one app, no `id` returns the visible list, `?id=` with a space still returns `200 []`, repeated `id` stays 400, `?id=` on `view` is still ignored; `?object=lead` still switches to that object's views and no `object` still lists them all (asserted inside the new object case). </details> <details><summary>Ablation (each leg red on both suites, then restored to the HEAD blob)</summary> - `id` leg (on 9246901): `--anchor " const appIdFilter = metaType === 'app' ? query?.id : undefined;" --replacement " const appIdFilter = metaType === 'app' && query?.id !== '' ? query?.id : ((globalThis as any).ABLATE_28548 = undefined);"`, which puts back the old skip. Anchor 1 → 0, blob 24ba18a → acc770c9; rebuilt; preflight hit `dist/index.js` + `dist/index.cjs`. Red: the rest case (`caller holding [studio.access]: expected 200 to be 400`) and runtime row A. Restored: blob == HEAD 24ba18a, `git diff HEAD` empty; rebuilt; `--absent` clean. - `object` leg (on 9c45ac5): `--anchor " if (metaType === 'view' && query?.object === '') refuseEmptyListFilter('object', 'object', 'view');" --replacement " if ((globalThis as any).ABLATE_28548B === 'on' && metaType === 'view' && query?.object === '') refuseEmptyListFilter('object', 'object', 'view');"`, which lets the empty value fall back to the old truthiness skip. Anchor 1 → 0, blob 3530175 → 15af187f; rebuilt; preflight hit `dist/index.js` + `dist/index.cjs`. Red: the rest object case (`view: expected 200 to be 400`, 1 failed / 26 passed) and runtime row A (1 failed / 934 passed). Restored: blob == HEAD 3530175, `git diff HEAD` empty; rebuilt; `--absent` clean over 6 built files, tree clean. </details> <details><summary>Package suites, typecheck, lint (head 9c45ac5)</summary> - `pnpm --filter @objectstack/rest test` :: exit 0 · Test Files 362 passed (362) · Tests 6907 passed, 332 skipped - `pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-list-projection-parity.test.ts src/domains/dispatcher-query-arity.test.ts` :: exit 0 · 968 passed (the parity file is the only runtime file changed; it is in the `repo` project, which CI's Test Core runs) - `pnpm --filter @objectstack/rest typecheck` :: exit 0 - `pnpm --filter @objectstack/runtime typecheck` :: exit 0 (`@objectstack/driver-turso` built first, a test-only import outside the closure) - `pnpm exec eslint --no-inline-config packages/rest packages/runtime` :: exit 0 </details> <details><summary>Dogfood battery (9c45ac5)</summary> `pnpm exec turbo run build --concurrency=2 --filter='@objectstack/dogfood^...'` then `pnpm --filter @objectstack/dogfood test` under `os-verify-lock.sh`, no shard, no path: `VERDICT command-exit 0` · Test Files 364 passed, 3 skipped (367) · Tests 2934 passed, 28 skipped (2962) · no failures. (Also green on 9246901 with the same counts.) </details> ## Acceptance notes - Producers checked for an empty `id` or `object`: `@objectstack/client`'s `meta.getItems(type, { packageId })` sends no `id`; no file under `ui/packages`, `ui/apps`, `packages/client/src` or `packages/cli/src` builds a `meta/app?id=` request, and the console reads `meta.getItems('view')` with no query. The QA ledger rows under `docs/qa/platform-checklist` that read `/meta/app?id=` all name an app; none asserts the empty answer. - The refusal body on `RestServer` is the flat `{ error, code }` its route error door writes for a thrown refusal (the same door `refuseUnknownMetaListType` uses on this route); the repeated-`id` 400 at the door entry writes the nested `{ error: { code, message } }`. Both carry `VALIDATION_ERROR`. The dispatcher writes its own envelope with the same `error.code`. Not changed here. - `?id=` with a space keeps its answer (`200 []`): the ruling keeps every non-empty `id` as it was. #27484 refused a whitespace-only `$searchFields`, but there the value is split into names and trimmed; an app name is compared whole. - The empty `?object=` refusal was folded in on the seat's direction (four adjacency conditions: same class, same file, mechanical, same gate family); the claim's file surface already covered identical empty-as-absent filters in this gate. - Branch `claude/project-thread-cq2t0t` is the card thread's harness-pinned branch, named in claim 6554674218; it carries no issue number. Signature: `domain:server#3 · os-project-manager · session_01FEkQ5pQoUJCqKk6SJQb6Qx`. - No open PR touched `packages/rest/src/meta-item-read-gate.ts` at the claim's read. ## Verification Local subset, head `9c45ac50`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 families; every one run with its exit code captured before any pipe, all 60 `exit 0` (`check:type-check-debt` under `os-verify-lock.sh`). `--ran` reconcile: "60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN". Three first-pass readings were not verdicts and were re-run once the parallel dogfood closure build had settled: `check:dts-closure` (exit 1 while that build was rewriting dists; 239/239 declaration files present on re-run), `check:dual-build-cjs-loads` (exit 3, missing dists; passed after the remaining seven were built) and `check:type-check-debt` (exit 99, lock held by the dogfood battery). `check-plugin-teardown-shape.mjs --self-test` ran after fetching its pinned fixture commit. The same 60 were all `exit 0` on 9246901. CI-run families: see this PR's check-runs. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes on the touched packages. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer summary block is the dispatch contract's Chinese section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pQoUJCqKk6SJQb6Qx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 819b101 commit f3f7b62

4 files changed

Lines changed: 128 additions & 12 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)!: an empty `?id=` on the app list and an empty `?object=` on the view list are refused 400 naming the parameter instead of being read as absent
6+
7+
Clause-②: no (narrowing) — `GET /meta/app` / `GET /meta/apps` with an empty `?id=`, and `GET /meta/view` / `GET /meta/views` with an empty `?object=` (on `RestServer` and on the runtime dispatcher, which share the list chain), are refused with `400 VALIDATION_ERROR`; they 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 whose filter names nothing 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 and view list reads (`@objectstack/rest`).
13+
14+
- **Before.** `?id=` (app list) and `?object=` (view list) with an empty value were read as no filter, so a caller who asked for one app, or for one object's views, by a name that came out blank got every app or every view it may see (the views unsorted, and including views bound to no object).
15+
- **Now.** The list chain refuses either after the per-caller visibility gate: `400 VALIDATION_ERROR`, message `Query parameter 'id' names no app; …` or `Query parameter 'object' names no object; …` (the message names the parameter), and no list in the body. This is the answer #27484 gave a blank `$searchFields`.
16+
- **Unchanged.** A non-empty value narrows as before (one app or one object's views, or an empty list when it names none, including `' '`); no value lists every item the caller may see; a repeated `id` or `object` is still the arity 400; `?id=` on any type but `app`, and `?object=` on any type but `view`, are still ignored.
17+
- **Who sees it.** An API client that sends either parameter empty. `@objectstack/client` and the console send neither on these reads.
18+
19+
**The remedy.** Name the app or object, or leave the parameter out to list them all.

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

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,67 @@ describe('#7566 — `GET /meta/app?id=` narrows the list instead of being droppe
283283
const { rest } = setup(['manage_users']);
284284

285285
expect(namesFrom((await getList(rest, 'app')).body).sort()).toEqual(['account', 'crm']);
286-
// `?id=` (empty) is the "no filter" spelling an unset `<select>` submits
287-
// — the same falsy gate `?package=` on this route has always used. It
288-
// must not become a new 400 or an empty list.
289-
expect(namesFrom((await getList(rest, 'app', { id: '' })).body).sort())
290-
.toEqual(['account', 'crm']);
286+
});
287+
288+
// [#28548] `?id=` (EMPTY) used to be read as absent and answered the whole
289+
// list — the #27484 class (a blank `$searchFields` read as absent). A
290+
// caller whose app name came out blank asked for ONE app and got every app
291+
// it may see, a plausible wrong answer. It is refused now, exactly as the
292+
// #27484 fix refuses a blank search-field override: `400 VALIDATION_ERROR`
293+
// naming the parameter, and no list rides along. No producer sends it:
294+
// `@objectstack/client`'s `meta.getItems` and the console send no `id`.
295+
it('an EMPTY id is refused with 400 naming the parameter — not read as absent (#28548)', async () => {
296+
for (const perms of [['studio.access'], ['manage_users'], []]) {
297+
const { rest } = setup(perms);
298+
const res = await getList(rest, 'app', { id: '' });
299+
300+
// Thrown to the route's error door, so it is the body that door
301+
// writes (the flat `{ error, code }` the unknown-type refusal on
302+
// this route answers too); the dispatcher's twin is pinned in
303+
// `@objectstack/runtime`'s list parity census.
304+
expect(res.statusCode, `caller holding [${perms}]`).toBe(400);
305+
expect(res.body?.code).toBe('VALIDATION_ERROR');
306+
expect(String(res.body?.error)).toMatch(/^Query parameter 'id' names no app/);
307+
expect(res.body?.items).toBeUndefined();
308+
expect(Array.isArray(res.body)).toBe(false);
309+
}
310+
// The plural spelling refuses identically.
311+
const plural = await getList(setup(['manage_users']).rest, 'apps', { id: '' });
312+
expect(plural.statusCode).toBe(400);
313+
expect(plural.body?.code).toBe('VALIDATION_ERROR');
314+
});
315+
316+
// [#28548] The same class one step down the chain: `?object=` (EMPTY) on
317+
// the view list was read as absent and served every view, unsorted and
318+
// including views bound to no object, instead of the switcher's list for
319+
// one object. Refused the same way, after the same gate.
320+
it('an EMPTY view `object` is refused with 400 naming the parameter — not read as absent (#28548)', async () => {
321+
for (const type of ['view', 'views']) {
322+
const { rest, protocol } = setup(['manage_users']);
323+
protocol.getMetaItems = vi.fn(async ({ type: t }: any) =>
324+
String(t ?? '').startsWith('view')
325+
? [{ name: 'all_leads', viewKind: 'list', object: 'lead' }, { name: 'loose_view' }]
326+
: []);
327+
const res = await getList(rest, type, { object: '' });
328+
329+
expect(res.statusCode, type).toBe(400);
330+
expect(res.body?.code).toBe('VALIDATION_ERROR');
331+
expect(String(res.body?.error)).toMatch(/^Query parameter 'object' names no object/);
332+
expect(res.body?.items).toBeUndefined();
333+
expect(Array.isArray(res.body)).toBe(false);
334+
335+
// Controls: a named object still switches, an absent one lists all.
336+
expect(namesFrom((await getList(rest, type, { object: 'lead' })).body)).toEqual(['all_leads']);
337+
expect(namesFrom((await getList(rest, type)).body).sort()).toEqual(['all_leads', 'loose_view']);
338+
}
339+
});
340+
341+
it('a WHITESPACE id keeps its answer — a value no app is named, so an empty list (#28548 control)', async () => {
342+
const { rest } = setup(['manage_users']);
343+
const res = await getList(rest, 'app', { id: ' ' });
344+
345+
expect(res.statusCode).toBe(200);
346+
expect(namesFrom(res.body)).toEqual([]);
291347
});
292348

293349
it('a MALFORMED id — supplied twice — is refused with 400, not silently resolved', async () => {

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

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3402,6 +3402,32 @@ export type MetaListAnswer =
34023402
| { ok: true; data: unknown; cacheControl: string; etag?: string }
34033403
| { ok: false; object: string };
34043404

3405+
/**
3406+
* [#28548] The list chain's answer to a narrowing filter sent EMPTY — `?id=`
3407+
* on the app list, `?object=` on the view list. Each was read as absent, so a
3408+
* caller whose name came out blank — who asked for ONE app, or for the views
3409+
* of ONE object — was answered every item it may see: the #27484 class (a
3410+
* blank `$searchFields` read as absent), refused the way that fix refuses it —
3411+
* `400 VALIDATION_ERROR` naming the parameter, so the caller learns its request
3412+
* named nothing instead of reading a plausible wrong list.
3413+
*
3414+
* Asked by steps 3 and 4 of {@link createMetaListAnswer}, AFTER the per-caller
3415+
* gate (step 2), which keeps its place. Only the empty string: a value that
3416+
* names nothing real (`' '`, `nope`) still narrows to the empty list. THROWS,
3417+
* so each transport's own `catch` shapes it (`RestServer`'s `handleRouteError`,
3418+
* the dispatcher's `errorFromThrown`), as {@link refuseUnknownMetaListType} does.
3419+
*/
3420+
function refuseEmptyListFilter(param: 'id' | 'object', names: 'app' | 'object', lists: 'app' | 'view'): never {
3421+
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.`,
3424+
);
3425+
err.status = 400;
3426+
err.code = 'VALIDATION_ERROR';
3427+
err.param = param;
3428+
throw err;
3429+
}
3430+
34053431
/** The items of a list shape this chain serves, or `null` for anything else (left alone, never replaced). */
34063432
function listItemsOf(raw: unknown): any[] | null {
34073433
if (Array.isArray(raw)) return raw;
@@ -3488,11 +3514,13 @@ async function describeMetaPositionList(sources: MetaListAnswerSources, list: an
34883514
* narrows (permission decides what exists for this caller; the filter
34893515
* narrows within it, never the reverse, ADR-0045 §3), and on the raw doc
34903516
* items, before the locale collapse (`_packageId` scopes membership).
3491-
* 3. `app` — `?id=<app>` narrows to the app of that `name` (#7566); empty
3492-
* and absent spellings mean no filter; no match is an EMPTY list, never a
3493-
* 404.
3517+
* 3. `app` — `?id=<app>` narrows to the app of that `name` (#7566); absent
3518+
* 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.
34943521
* 4. `view` — `?object=<object>` keeps the independent views bound to that
3495-
* object, sorted by `order` then `name` (the switcher).
3522+
* object, sorted by `order` then `name` (the switcher); [#28548] an EMPTY
3523+
* value is refused (`400 VALIDATION_ERROR`), never read as absent.
34963524
* 5. `doc` — the ADR-0046 locale collapse (the request's own preference; the
34973525
* `translations` map is dropped whatever the locale).
34983526
* 6. `doc` — the ADR-0046 content slim, unless `?include=content`.
@@ -3546,9 +3574,11 @@ export function createMetaListAnswer(
35463574
}
35473575
}
35483576

3549-
// 3. [#7566] `?id=<app>`.
3577+
// 3. [#7566] `?id=<app>`. [#28548] An EMPTY one is refused, never
3578+
// read as absent (see {@link refuseEmptyListFilter}).
35503579
const appIdFilter = metaType === 'app' ? query?.id : undefined;
3551-
if (typeof appIdFilter === 'string' && appIdFilter !== '') {
3580+
if (appIdFilter === '') refuseEmptyListFilter('id', 'app', 'app');
3581+
if (typeof appIdFilter === 'string') {
35523582
const list = listItemsOf(visible);
35533583
if (list) {
35543584
visible = withItems(visible, list.filter(
@@ -3557,7 +3587,9 @@ export function createMetaListAnswer(
35573587
}
35583588
}
35593589

3560-
// 4. `?object=<object>` — the view switcher.
3590+
// 4. `?object=<object>` — the view switcher. [#28548] An EMPTY one is
3591+
// refused, never read as absent (see {@link refuseEmptyListFilter}).
3592+
if (metaType === 'view' && query?.object === '') refuseEmptyListFilter('object', 'object', 'view');
35613593
if (metaType === 'view' && query?.object) {
35623594
const obj = String(query.object);
35633595
const list = listItemsOf(visible);

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,8 +522,13 @@ const PARAM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record
522522
{ label: '(none)', query: {} },
523523
{ label: '?id=crm', param: 'id', query: { id: 'crm' } },
524524
{ label: '?id=nope', param: 'id', query: { id: 'nope' } },
525+
// [#28548] An EMPTY app name is refused (400 VALIDATION_ERROR) on both
526+
// transports, never read as absent.
527+
{ label: '?id= (empty)', param: 'id', query: { id: '' } },
525528
{ label: '?object=lead', param: 'object', query: { object: 'lead' } },
526529
{ label: '?object=nope', param: 'object', query: { object: 'nope' } },
530+
// [#28548] …and so is an EMPTY view object.
531+
{ label: '?object= (empty)', param: 'object', query: { object: '' } },
527532
{ label: '?include=content', param: 'include', query: { include: 'content' } },
528533
// [#25333] The position list's opt-in description.
529534
{ label: '?include=grants', param: 'include', query: { include: 'grants' } },
@@ -680,8 +685,12 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\
680685
const { read } = bootRest('holder');
681686
expect(names((await read('/meta/app', { id: 'crm' })).items)).toEqual(['crm']);
682687
expect((await read('/meta/app', { id: 'nope' })).items).toEqual([]);
688+
const blank = await read('/meta/app', { id: '' });
689+
expect({ status: blank.status, code: blank.code, items: blank.items }).toEqual({ status: 400, code: 'VALIDATION_ERROR', items: undefined });
683690
expect(names((await read('/meta/view', { object: 'lead' })).items)).toEqual(['lead_mine', 'lead_all']);
684691
expect((await read('/meta/views', { object: 'nope' })).items).toEqual([]);
692+
const blankObject = await read('/meta/views', { object: '' });
693+
expect({ status: blankObject.status, code: blankObject.code, items: blankObject.items }).toEqual({ status: 400, code: 'VALIDATION_ERROR', items: undefined });
685694
const docs = await read('/meta/docs');
686695
expect(text(docs)).not.toContain('Welcome aboard.');
687696
expect(text(await read('/meta/docs', { include: 'content' }))).toContain(DOC_SECRET);

0 commit comments

Comments
 (0)