Skip to content

Commit 6caca51

Browse files
fix(metadata-protocol)!: refuse a $searchFields entry naming a secret / password field at the data door (#27936)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27829 Clause-②: no (narrowing) — the data doors (`GET /data/:object`, `POST /data/:object/query`, and every other caller of `findData`) refuse a search-field override naming a `secret` / `password` field, which they answered 200 over the rest of the declared search set before. Nothing is widened. Metadata-protocol: none Skills-docs: none — no skill teaches or emits a credential name in `searchFields` (`git grep -n "searchFields\|searchableFields" -- skills` at this head: no hit mentions a `secret` / `password` / credential field); the one docs page that lists the search refusals gains the row here (`content/docs/api/data-api.mdx`). ## 维护者速读 - **改了什么**:对象的 `searchableFields` 声明了 `secret` / `password` 字段时,请求里 `$searchFields`(或 POST 的 `searchFields` / 对象形 `search.fields`)点名该字段,数据接口现在回 400 `INVALID_FIELD` 并点名字段,不再读库。 - **为什么改**:以前门放行这个名字,引擎又按凭据规则把该列从搜索里拿掉,结果悄悄搜了声明里的其余字段:只想搜 `api_key`,拿到的是按 `name` 匹配的行,看不出任何异常(不泄露凭据,但答非所问)。 - **风险与回滚**:只影响在 `$searchFields` 里点名凭据字段的调用方;Console 列表搜索会原样回传视图的 `searchableFields`,若某视图声明了凭据字段,搜索会从 200 变 400(lint 早已把这种声明报为错误)。引擎侧的凭据隐藏不动,仍是兜底。回滚 = revert 本 PR。 - **先看**:`packages/metadata-protocol/src/protocol-query-validation.ts`(`assertSearchFieldsAreSearchable` 新增的 `credential` 一支与它的文案),`packages/rest/src/data-search-fields-credential-door.test.ts`(门级钉子)。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/data-search-fields-credential-door.test.ts`,预期 6 个用例全过(secret、password 各三:GET 拒收、POST 拒收、对照组照旧)。 - **需要你做的**:无,席位落地(进队前欠一次契约复核)。 ## What changes - **`assertSearchFieldsAreSearchable`** (`metadata-protocol/src/protocol-query-validation.ts`): after the allowed set is resolved, a requested name the set KEPT whose field type is a credential (`isCredentialFieldType`, `@objectstack/spec/data`, the predicate the engine's `collectCredentialFields` withhold reads) is refused `400 INVALID_FIELD` with `field`, `fields`, `object` and `param` set, in the envelope every other refusal of this gate uses. Message: `Field 'api_key' on object 'OBJ' is type 'secret', a credential, and cannot be searched. …`, the wording family the auto-default path already gives a credential name ("is type 'secret'"), plus the remedy: remove it from `searchableFields` when the object declares it, search another column. Only a declared `searchableFields` keeps a credential in the allowed set (the auto default excludes the types and still refuses them with its own message, unchanged). - It covers the standalone slot in every spelling and the object-form `search.fields`, because `protocol.ts` hands both to this one gate (measured: the POST legs below). No line of the hot file `protocol.ts` moves. - Order in the cascade: unknown → stale declaration → virtual → **credential** → unsearchable, so a request with a typo still gets the typo answer first. - The engine is unchanged: its credential withhold (ADR-0100) stays as the fail-closed floor for direct engine callers. - New door pin `rest/src/data-search-fields-credential-door.test.ts`: real `RestServer` → `findData` → `ObjectQL` → sqlite, the #27484 harness; two objects declaring `searchableFields: [name, api_key]` with `api_key` `secret` and `password`. GET `$searchFields=api_key` and `$searchFields=name,api_key`, POST `searchFields: ['api_key']` and object-form `search.fields: ['name', 'api_key']` each answer 400 `INVALID_FIELD` naming `api_key` and its type, with zero reads of the object; controls `$searchFields=name`, no `$searchFields`, and POST `searchFields: ['name']` answer 200 with the two `name` matches. - Docs: one row in the refusal table of `content/docs/api/data-api.mdx`. - Changeset `.changeset/27829-search-fields-credential-refused.md` (`patch` on `@objectstack/metadata-protocol`, BREAKING banner, ADR-0087 `not-required`). ## Tests Head `f6a224d1` (merge of `origin/main` `d770b150` onto the fix commit `1d6b7d0d`). Every reading below is from this head unless it says otherwise. <details><summary>Red first, then green (door pin)</summary> - Before the fix (the new test against the gate at the base `637cfc67`): `data-search-fields-credential-door.test.ts` 2 failed / 2 passed of 4 — `$searchFields=api_key` answered `200` with `qq7-a`, `qq7-b` (matched in `name`), for both `secret` and `password`: the card's reading. - After: 6/6 (the POST legs were added with the fix). At this head: `data-search-fields-credential-door.test.ts` + `data-empty-search-fields-door.test.ts` 11/11. </details> <details><summary>Package suites and typecheck</summary> - `pnpm --filter @objectstack/metadata-protocol test` :: exit 0 · Test Files 282 passed, 4 skipped (286) · Tests 21843 passed, 26 skipped (this head; under `os-verify-lock.sh`, VERDICT command-exit 0) - `pnpm --filter @objectstack/metadata-protocol typecheck`, `pnpm --filter @objectstack/rest typecheck` :: exit 0 (this head) - `pnpm --filter @objectstack/rest test` :: exit 0 · Test Files 348 passed (348) · Tests 6746 passed, 332 skipped (on `1d6b7d0d`; the merge brought no `rest` change) - eslint `--no-inline-config` over `packages/metadata-protocol` and `packages/rest` :: exit 0 </details> <details><summary>Ablation (red, then restored to the HEAD blob)</summary> - `node scripts/ablation-replace.mjs --file packages/metadata-protocol/src/protocol-query-validation.ts --anchor ": credential.length > 0 ? [credential, 'credential' as const]" --replacement ": (globalThis as any).ABLATE_27829 === 'refuse' && credential.length > 0 ? [credential, 'credential' as const]"` — anchor 1 → 0, blob `3028c46e` → `774faa00`; rebuilt; `ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATE_27829` hit in `dist/index.js` and `dist/index.cjs`. - Red: 4 failed / 2 passed of 6 — all four refusal legs (GET and POST, `secret` and `password`) answered `200` with the `name` matches; the two control legs stayed green. - Restored: blob after restore == HEAD `3028c46e`, `git diff HEAD` empty; rebuilt; preflight `--absent` clean over 24 built files, tree clean; door test 6/6 again. </details> <details><summary>Dogfood battery (this head)</summary> `pnpm exec turbo run build --concurrency=2 --filter='./packages/**'` (76/76) then `pnpm --filter @objectstack/dogfood test` under `os-verify-lock.sh`, no shard, no path: `VERDICT command-exit 0` · Test Files 357 passed, 3 skipped (360) · Tests 2875 passed, 20 skipped (2895) · no failures. An earlier run on `c55d2e92` (an older merge of `main`) had 4 failures in 3 files (`error-sentences-live` ×2, `object-hook-lifecycle-scratch`, `platform-app-object-entry-views`), none search-related; `main` fixed them in #27878 and #27873, and this head includes both. </details> ## Verification Local subset, head `f6a224d1`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 90 families; every one run with its exit code captured before any pipe, all 90 `exit 0` (`check:type-check-debt` under `os-verify-lock.sh`). `--ran` reconcile: "90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: every entry carries its exit code). On the first pass (`1d6b7d0d`) `spec check:skill-examples` and `check:dual-build-cjs-loads` answered exit 3 (PREREQUISITE NOT MET, dists unbuilt); both are exit 0 here after the full package build, not read as a verdict before. CI-run families: see this PR's check-runs. ## Acceptance notes - Producer that will see the new 400: the Console's list search (`ui/packages/plugin-list/src/ListView.tsx`, `ui/packages/plugin-grid/src/objectGridRowLoad.ts`) sends the VIEW's `searchableFields` as `$searchFields` verbatim. A view declaring a credential entry used to search the rest of its list silently; it now answers 400 naming the field. `validate-searchable-fields` (`@objectstack/lint`) already reports that declaration as an error on every surface, so the fix for the author is the one the lint names. - The `declared` branch of the existing "not searchable" message lists the allowed set (`allowed.join(', ')`), which still names a declared credential entry. Cosmetic, not changed here. - In-flight intersection on the hot file: at branch time the only open PR touching `metadata-protocol/src/protocol.ts` was #27860 (head `fd02314b`); `git merge-tree --write-tree` of this branch against it was clean (exit 0), and it has since merged into `main`, which this head includes. No open PR touches the other changed files. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2f434d commit 6caca51

4 files changed

Lines changed: 198 additions & 1 deletion

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol)!: a `$searchFields` entry naming a `secret` / `password` field is refused 400 `INVALID_FIELD` at the data door, even when the object's `searchableFields` declares it, instead of searching the rest of the declared set (#27829)
6+
7+
Clause-②: no (narrowing) — the data doors (`GET /data/:object`, `POST /data/:object/query`, and every other caller of `findData`) refuse a search-field override naming a credential field, which they answered 200 over the rest of the declared search set before. Nothing is widened.
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 query value naming a credential field is refused where the engine silently dropped it. The fix for a caller is to search another field, which the refusal says; there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the query door (`@objectstack/metadata-protocol`), served by every Data API door.
13+
14+
- **Before.** On an object whose `searchableFields` declares a `secret` or `password` field, `?$search=TERM&$searchFields=api_key` passed the door, the engine withheld the credential column from the scan (ADR-0100), and its tolerant intersection then searched the rest of the declared set: 200 with rows matched in `name`. `$searchFields=name,api_key` silently dropped `api_key`. The credential itself was never searched.
15+
- **Now.** `400 INVALID_FIELD` naming the field (`Field 'api_key' on object 'OBJ' is type 'secret', a credential, and cannot be searched`), before any row is read — the same answer the auto-default path already gave a credential name. It covers the standalone `searchFields` / `$searchFields` slot and the object-form `search.fields`. Judged by `isCredentialFieldType` (`@objectstack/spec/data`), the predicate the engine's withhold reads.
16+
- **Unchanged.** A request that names no credential field is served as before; with no `$searchFields`, the declared set is searched and the declared credential entry is never scanned. The engine's withhold stays as the floor for direct engine callers.
17+
- **Who sees it.** An API caller naming a credential field in `$searchFields`, typically a client echoing the object's `searchableFields` verbatim on an object that declares one (the linter has flagged that declaration since `validate-searchable-fields` learned the credential check).
18+
19+
**The remedy.** Remove the credential field from the object's `searchableFields` and from the request; search another column.

‎content/docs/api/data-api.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ corrupts something the earlier axes do not:
156156
| `?search=alpha&searchFields=no_such_field` | `400 INVALID_FIELD` |
157157
| `?search=alpha&searchFields=` (or `=,`, or `[]` in a body) | `400 VALIDATION_ERROR` — names no field, the answer `$select=` gets; leave the parameter out to scan the default set |
158158
| `?search=alpha&searchFields=amount` | `400 INVALID_FIELD` — real field, but not searchable |
159+
| `?search=alpha&searchFields=api_key` (a `secret` or `password` field, even one `searchableFields` declares) | `400 INVALID_FIELD` — a credential is never searched; remove it from `searchableFields` too |
159160
| `?search=alpha&searchFields=project_id.name` | `400 INVALID_FIELD` — search scans this object's own columns; mirror the related title instead (see below) |
160161
| `?search=alpha` on an object with no searchable field (two lookups and a `formula` title, say) | `400 INVALID_FIELD` — nothing to scan; declare a stored field searchable or mirror the title (see below) |
161162
| `groupBy: ["status"]` | one bucket per status value |

‎packages/metadata-protocol/src/protocol-query-validation.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js';
4646
import { FILTER_LOGICAL_KEYS, ODATA_SPELLING, QUERY_PARAM_NEAR_MISS, collectFilterReads, invalidSortError, isObjectOfDisabledPackage, isPlainRecord, nearMissKey, objectPackageDisabledError, quotedQueryValue, suggestFieldName } from './protocol.js';
4747
import { objectNotFoundError, relationConditionSites } from '@objectstack/core';
4848
import { collectInternalWriteResponseFields } from './write-response-internal-fields.js';
49-
import { AggregationFunction, DateGranularity, REFERENCE_VALUE_TYPES, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, apiExposureDenialReason, canServeApiOperation, classifyDottedFilterHead, effectiveOperationsArray, isFilterAST, isVirtualSearchField, parseFilterAST, referenceTargetOf, resolveEffectiveApiMethods, resolveSearchFieldResolution } from '@objectstack/spec/data';
49+
import { AggregationFunction, DateGranularity, REFERENCE_VALUE_TYPES, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, apiExposureDenialReason, canServeApiOperation, classifyDottedFilterHead, effectiveOperationsArray, isCredentialFieldType, isFilterAST, isVirtualSearchField, parseFilterAST, referenceTargetOf, resolveEffectiveApiMethods, resolveSearchFieldResolution } from '@objectstack/spec/data';
5050

5151
/**
5252
* [#20802] The nested-relation spelling of a dotted relation path, as the
@@ -959,10 +959,22 @@ export function assertSearchFieldsAreSearchable(
959959
// types have a column.
960960
const virtual = names.filter((n) => !allowedSet.has(n) && gate.known.has(n) && isVirtualSearchField(gate.fields[n]));
961961
const unsearchable = names.filter((n) => !allowedSet.has(n) && gate.known.has(n) && !isVirtualSearchField(gate.fields[n]));
962+
// [#27829] A CREDENTIAL field (`secret` / `password`) the allowed set kept —
963+
// only a declared `searchableFields` keeps one; the auto default never does,
964+
// and refuses it below by its type — is refused here too. The engine
965+
// withholds every credential column from the scan (ADR-0100, the
966+
// fail-closed floor), so admitting the name let the engine's tolerant
967+
// intersection search the REST of the declared set: a narrower search
968+
// answered with a wider one, the #4254 posture this gate exists for.
969+
// Judged by the predicate the engine's withhold reads
970+
// (`isCredentialFieldType`, `@objectstack/spec/data`), so the door and the
971+
// engine cannot disagree about which fields are credentials.
972+
const credential = names.filter((n) => allowedSet.has(n) && isCredentialFieldType(gate.fields[n]?.type));
962973
const [offenders, reason] =
963974
unknown.length > 0 ? [unknown, 'unknown' as const]
964975
: staleDeclared.length > 0 ? [staleDeclared, 'stale-declared' as const]
965976
: virtual.length > 0 ? [virtual, 'virtual' as const]
977+
: credential.length > 0 ? [credential, 'credential' as const]
966978
: [unsearchable, 'unsearchable' as const];
967979
if (offenders.length === 0) return;
968980
const first = offenders[0];
@@ -985,6 +997,18 @@ export function assertSearchFieldsAreSearchable(
985997
+ 'look like coverage; remove it there as well.'
986998
: '')
987999
+ ` Mirror the computed value onto a stored text field on '${object}' and search that instead.`;
1000+
} else if (reason === 'credential') {
1001+
detail = `Field '${first}' on object '${object}' is type '${gate.fields[first]?.type}', a credential, `
1002+
+ 'and cannot be searched'
1003+
+ (offenders.length > 1 ? ` (also: ${offenders.slice(1).join(', ')})` : '')
1004+
+ `. 'search' never scans a credential column, so '${param}' naming it cannot narrow `
1005+
+ 'anything — the engine used to drop it and scan the rest of the declared columns instead, '
1006+
+ 'answering a NARROWER search with a WIDER one.'
1007+
+ (declaredSet.has(first)
1008+
? " The object's 'searchableFields' declares it, which is what made the entry look searchable; "
1009+
+ 'remove it there as well.'
1010+
: '')
1011+
+ ' Search another column instead.';
9881012
} else if (reason === 'unknown') {
9891013
// A dotted path is a special unknown: plausible vocabulary from the
9901014
// select/sort axes, but search scans this object's own columns.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27829] A `$searchFields` entry naming a CREDENTIAL field (`secret` /
5+
* `password`) is refused `400 INVALID_FIELD` at the Data API door, naming the
6+
* field, even when the object's own `searchableFields` declares it — over a
7+
* real `SqlDriver` (SQLite), the harness of `data-empty-search-fields-door.test.ts`.
8+
*
9+
* Before: the door (`assertSearchFieldsAreSearchable`) resolved the allowed set
10+
* from the declaration, which kept the credential entry, so the request passed;
11+
* the engine then withheld the credential column from the scan (ADR-0100) and,
12+
* by its tolerant intersection, searched the REST of the declared set.
13+
* `?$search=…&$searchFields=api_key` answered 200 with rows matched in `name` —
14+
* a narrower search answered with a wider one. The engine's withhold stays as
15+
* the fail-closed floor; this file pins the door's half: the status, `code`,
16+
* named field, that the driver was never asked, and the controls unchanged.
17+
*/
18+
19+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
20+
import { ObjectQL } from '@objectstack/objectql';
21+
import { SqlDriver } from '@objectstack/driver-sql';
22+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
23+
import { RestServer } from './rest-server';
24+
import { installDefaultOrganization } from './default-organization.testkit.js';
25+
26+
/** object name · the credential type its `api_key` field carries. */
27+
const OBJECTS = [
28+
['rest_search_credential_secret_27829', 'secret'],
29+
['rest_search_credential_password_27829', 'password'],
30+
] as const;
31+
32+
const NAMES = ['qq7-a', 'qq7-b', 'other'];
33+
34+
/** The term every case searches: in two rows' `name`. */
35+
const TERM = 'qq7';
36+
37+
function createMockServer() {
38+
const noop = () => {};
39+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
40+
}
41+
42+
function makeRes() {
43+
const res: any = {
44+
write: () => true, end: () => {},
45+
header: () => res,
46+
status: (code: number) => { res._status = code; return res; },
47+
json: (body: any) => { res._json = body; return res; },
48+
};
49+
return res;
50+
}
51+
52+
describe('[#27829] $searchFields naming a declared credential field on the Data API door — sqlite', () => {
53+
let engine: ObjectQL;
54+
let driver: any;
55+
const reads = { n: 0 };
56+
let list: (object: string, query: Record<string, string>) => Promise<{ status: number; body: any }>;
57+
let query: (object: string, body: Record<string, unknown>) => Promise<{ status: number; body: any }>;
58+
59+
beforeAll(async () => {
60+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any);
61+
engine = new ObjectQL();
62+
engine.registerDriver(driver, true);
63+
await engine.init();
64+
for (const [name, ctype] of OBJECTS) {
65+
engine.registry.registerObject({
66+
name,
67+
label: `Credential ${ctype} 27829`,
68+
searchableFields: ['name', 'api_key'],
69+
fields: {
70+
name: { name: 'name', type: 'text' as const },
71+
api_key: { name: 'api_key', type: ctype },
72+
},
73+
} as any);
74+
}
75+
await engine.syncSchemas();
76+
// A real `single` install's Default Organization (ADR-0131 D9, D1).
77+
await installDefaultOrganization(engine);
78+
for (const [object] of OBJECTS) {
79+
for (const [i, name] of NAMES.entries()) await engine.insert(object, { id: `r${i}`, name } as any);
80+
}
81+
82+
const watched = new Set<string>(OBJECTS.map(([o]) => o));
83+
for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) {
84+
const real = driver[verb].bind(driver);
85+
driver[verb] = (o: string, ...rest: unknown[]) => { if (watched.has(o)) reads.n += 1; return real(o, ...rest); };
86+
}
87+
88+
const protocol = new ObjectStackProtocolImplementation(engine as any);
89+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
90+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
91+
rest.registerRoutes();
92+
const getRoute = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object');
93+
expect(getRoute, 'GET /api/v1/data/:object').toBeDefined();
94+
list = async (object, q) => {
95+
const res = makeRes();
96+
// What a querystring carries: strings.
97+
await getRoute!.handler({ params: { object }, query: { $orderby: 'name', $search: TERM, ...q }, headers: {} } as any, res);
98+
return { status: res._status ?? 200, body: res._json };
99+
};
100+
const postRoute = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query');
101+
expect(postRoute, 'POST /api/v1/data/:object/query').toBeDefined();
102+
query = async (object, body) => {
103+
const res = makeRes();
104+
// What the wire carries: JSON.
105+
await postRoute!.handler({ params: { object }, body: JSON.parse(JSON.stringify(body)), query: {}, headers: {} } as any, res);
106+
return { status: res._status ?? 200, body: res._json };
107+
};
108+
});
109+
110+
afterAll(async () => {
111+
try { await engine?.destroy(); } catch { /* noop */ }
112+
});
113+
114+
for (const [object, ctype] of OBJECTS) {
115+
it(`${ctype}: $searchFields=api_key and $searchFields=name,api_key are 400 INVALID_FIELD naming api_key — no read`, async () => {
116+
const before = reads.n;
117+
for (const searchFields of ['api_key', 'name,api_key']) {
118+
const res = await list(object, { $searchFields: searchFields });
119+
const label = `$searchFields=${searchFields}: ${JSON.stringify(res.body)}`;
120+
expect(res.status, label).toBe(400);
121+
expect(res.body.code, label).toBe('INVALID_FIELD');
122+
expect(res.body.error, label).toContain(`Field 'api_key' on object '${object}' is type '${ctype}'`);
123+
}
124+
expect(reads.n - before, 'no read of the object — the refusal precedes the driver').toBe(0);
125+
});
126+
127+
it(`${ctype}: POST query — searchFields and the object-form search.fields naming api_key are the same refusal — no read`, async () => {
128+
const before = reads.n;
129+
for (const body of [{ search: TERM, searchFields: ['api_key'] }, { search: { query: TERM, fields: ['name', 'api_key'] } }]) {
130+
const res = await query(object, body);
131+
const label = `${JSON.stringify(body)}: ${JSON.stringify(res.body)}`;
132+
expect(res.status, label).toBe(400);
133+
expect(res.body.code, label).toBe('INVALID_FIELD');
134+
expect(res.body.error, label).toContain(`Field 'api_key' on object '${object}' is type '${ctype}'`);
135+
}
136+
expect(reads.n - before, 'no read of the object — the refusal precedes the driver').toBe(0);
137+
});
138+
139+
it(`${ctype}: controls — $searchFields=name narrows, no $searchFields searches the declared set, as before`, async () => {
140+
const narrowed = await list(object, { $searchFields: 'name' });
141+
expect(narrowed.status, JSON.stringify(narrowed.body)).toBe(200);
142+
expect(narrowed.body.records.map((r: any) => r.name)).toEqual(['qq7-a', 'qq7-b']);
143+
144+
const all = await list(object, {});
145+
expect(all.status, JSON.stringify(all.body)).toBe(200);
146+
expect(all.body.records.map((r: any) => r.name)).toEqual(['qq7-a', 'qq7-b']);
147+
148+
const posted = await query(object, { search: TERM, searchFields: ['name'] });
149+
expect(posted.status, JSON.stringify(posted.body)).toBe(200);
150+
expect(posted.body.records).toHaveLength(2);
151+
});
152+
}
153+
});

0 commit comments

Comments
 (0)