diff --git a/.changeset/28120-in-process-search-override-no-field.md b/.changeset/28120-in-process-search-override-no-field.md new file mode 100644 index 00000000000..a2d68e1aa97 --- /dev/null +++ b/.changeset/28120-in-process-search-override-no-field.md @@ -0,0 +1,20 @@ +--- +'@objectstack/objectql': patch +'@objectstack/metadata-protocol': patch +'@objectstack/spec': patch +--- + +fix(objectql,metadata-protocol)!: an in-process search-field override that names no searchable field is refused, never widened to a default set (#28120) + +Clause-②: no (narrowing) — two in-process tolerances the Data API doors already refuse are closed. The stored-metadata reader seam (`narrowStoredMetadataSearch`) no longer narrows an override that names no field (an empty list, a comma string with no name, a list with no string entry): it passes it to the engine, which refuses it. And the engine (`find`, `findOne`, `count`, `aggregate`) refuses a `searchFields` or object-form `search.fields` that names none of the fields the search scans, where `resolveSearchFields` used to drop the names and scan the whole default set. No export, answer member, status or error code is added: the refusals are the doors' `VALIDATION_ERROR` / 400 and `INVALID_FIELD` / 400. Folds #28121. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the in-process search override (`@objectstack/objectql`, `@objectstack/metadata-protocol`). + +- **Before.** A host handler, hook or flow reading `sys_metadata` / `sys_metadata_history` through the reader seam with `searchFields: []` was served: the seam read the empty list as "names none" and wrote the narrowed default set over it. On any object, a search whose override named no searchable field (every name unknown, unsearchable or withheld) was resolved to the object's default search set. +- **Now.** The empty override reaches the engine's refusal through the seam: `400 VALIDATION_ERROR`, before any driver read. An override naming no searchable field is refused `400 INVALID_FIELD` (`param: 'searchFields'` or `'search.fields'`, `field` / `fields` naming what the caller wrote) before any driver read, the code the REST door answers for the same value. `mapDataError` serves both with the door's status and code. +- **Unchanged.** An absent (or `null`) override searches the default set. An override naming no searchable field sent without a `search` is still not judged in process, since nothing is scanned (the door refuses it with or without a search term). A named override narrows as before. An override naming SOME searchable field still has its other names dropped in process (the doors refuse them). An object with no searchable field at all keeps its own refusal (#24284). + +**The remedy.** Name at least one field the object searches (its `searchableFields`, or the default set), or leave the key out to search the default fields. diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index acbe6ba6a42..d3676c199cc 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -545,7 +545,9 @@ override that names no field at all (`?$searchFields=`, `?$searchFields=,`, an e array) is `400 VALIDATION_ERROR`, as `$select=` is; leave the parameter out to scan the default set. The engine used to drop unknown names and fall back to the full searchable set — a parameter that exists to narrow a search, silently widening it. Internal callers -reaching `engine.find()` directly are unaffected. +reaching `engine.find()` directly get the same two refusals for an override naming no field +(`400 VALIDATION_ERROR`) and one naming none of the searchable fields (`400 INVALID_FIELD`); +an override naming at least one searchable field there still has its other names dropped. ### Searching by a related record's title — mirror the value diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index f71d6ee24a1..e0d83c431e8 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -1190,7 +1190,10 @@ plus short-text/enum fields), so naming a field outside that set can never widen search — and over the REST/protocol ingress it is `400 INVALID_FIELD` outright (#4254), because the engine-side intersection alone used to drop the unknown name and fall back to scanning the full searchable set. Internal callers reaching `engine.find()` directly keep -the tolerant intersection, but not a search with nothing to scan: when no +the tolerant intersection for an override naming at least one searchable field (the other +names are dropped), but an override naming none of them is refused there too, +`400 INVALID_FIELD`, rather than widened to the full set. Nor is a search with nothing to +scan answered: when no `searchableFields` entry names a stored field and the auto-default finds none (`internal: true` fields never count), the engine itself refuses a non-blank `search` with `400 INVALID_FIELD` (`param: 'search'`) on `find`, `findOne`, `count` and diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index cd38eeee5e6..2165ab5dd73 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5186,18 +5186,28 @@ export interface StoredMetadataSearchSchema { * string or an array) — naming one is refused, `INVALID_FIELD` / 400, the * evaluate refusals' envelope, under the caller's wire spelling of the slot * (`wireSpelling`, the door's; the bare canonical name when absent); + * - [#28120] an explicit list that names NO field — an empty array, a blank or + * comma-only string, an array with no string entry — is not "names none": it + * is passed through untouched, for the engine's own refusal to answer + * (`400 VALIDATION_ERROR` for an empty one, #27732; `400 INVALID_FIELD` for + * one naming no searchable field). Narrowing it here wrote a non-empty set + * back over the caller's list, so an override the door and the engine both + * refuse was served. The door never reaches this with one: its ingress gate + * refuses it first; * - a search that names none is NARROWED: the object's resolved searchable set * ({@link resolveSearchFieldResolution}) minus those columns is returned, for * the caller to run as `searchFields` — the engine intersects an override * with that set and never widens it; * - a set that narrows to nothing is refused rather than returned empty: an - * empty override is ABSENT to the engine, which would then scan the whole - * default set, these columns included. + * empty override is refused by the engine as one naming no field, a + * caller's mistake this caller did not make, so the family's own refusal + * names the cause instead. * * Returns `undefined` when the query runs as it is — an object outside the - * family, no search, an explicit list that passes, or no readable field map - * (the engine has none to expand a search over either; the same rule the door's - * field gates apply: a non-object, array or empty `fields`). Throws the refusal. + * family, no search, an explicit list that passes or names no field, or no + * readable field map (the engine has none to expand a search over either; the + * same rule the door's field gates apply: a non-object, array or empty + * `fields`). Throws the refusal. */ export function narrowStoredMetadataSearch( object: string, @@ -5222,6 +5232,8 @@ export function narrowStoredMetadataSearch( if (refusal) throw refusal; return undefined; } + // [#28120] A list naming no field is the engine's to refuse, never narrowed. + if (typeof explicit === 'string' || Array.isArray(explicit)) return undefined; if (search == null) return undefined; const fields = schema?.fields; if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length === 0) { diff --git a/packages/objectql/src/engine-empty-search-fields-refusal.test.ts b/packages/objectql/src/engine-empty-search-fields-refusal.test.ts index d8e9257d103..c6c14c0bf77 100644 --- a/packages/objectql/src/engine-empty-search-fields-refusal.test.ts +++ b/packages/objectql/src/engine-empty-search-fields-refusal.test.ts @@ -221,3 +221,84 @@ describe('[#27732] the in-process engine refuses a search-field override naming expect(await answered(engine, 'find', { search: 'harbour', searchFields: null })).toEqual(DEFAULT_ANSWER.find); }); }); + +/** + * [#28120] …and an override that names no SEARCHABLE field — every name + * unknown, or no entry a field name at all — is refused `400 INVALID_FIELD`, + * the code the door answers the same value with (`assertSearchFieldsAreSearchable`, + * #4254). `resolveSearchFields` dropped the unknown names and fell back to the + * whole default set, so a typo or a renamed field searched every default + * column instead of the one the caller meant. An override naming at least one + * searchable field keeps the engine's tolerance: the other names are dropped. + */ +describe('[#28120] the in-process engine refuses a search-field override naming no searchable field', () => { + let engine: ObjectQL; + let reads: Array<{ via: string; where: unknown }>; + + beforeEach(async () => { + engine = new ObjectQL(); + const made = makeDriver(); + reads = made.reads; + engine.registerDriver(made.driver, true); + await engine.init(); + engine.registry.registerObject(account, 'test-package'); + }); + + describe.each(VERBS)('%s', (verb) => { + it('an all-unknown searchFields list is refused 400 INVALID_FIELD, before any driver read', async () => { + const answer = await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: ['nmae', 'town'] })); + expect(answer).toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields' }); + expect(reads).toEqual([]); + }); + + it('the comma-string and object-form spellings are refused the same way', async () => { + expect(await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: 'nmae' }))) + .toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields' }); + expect(await wireAnswer(run(engine, verb, { search: { query: 'harbour', fields: ['nmae'] } }))) + .toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'search.fields' }); + expect(reads).toEqual([]); + }); + + it('a list with no field-name entry at all is refused too', async () => { + expect(await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: [42] }))) + .toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields' }); + expect(reads).toEqual([]); + }); + + it('CONTROL: a partly-known override still narrows to its known names (the tolerance kept)', async () => { + expect(await answered(engine, verb, { search: 'harbour', searchFields: ['name', 'nmae'] })).toEqual(NAME_ONLY_ANSWER[verb]); + }); + + it('CONTROL: a valid override narrows and an absent one searches the default set, as before', async () => { + expect(await answered(engine, verb, { search: 'harbour', searchFields: ['name'] })).toEqual(NAME_ONLY_ANSWER[verb]); + expect(await answered(engine, verb, { search: 'harbour' })).toEqual(DEFAULT_ANSWER[verb]); + }); + }); + + it('the refusal names the field the caller wrote', async () => { + const err: any = await engine.find(account.name, { search: 'harbour', searchFields: ['nmae'] }).catch((e: unknown) => e); + expect(err).toMatchObject({ field: 'nmae', fields: ['nmae'], object: account.name }); + expect(err.message).toContain("names no searchable field in 'searchFields'"); + }); + + it('the REST door answers the same status and code for the same override', async () => { + const protocol = new ObjectStackProtocolImplementation(engine); + const door = await wireAnswer(protocol.findData({ object: account.name, query: { search: 'harbour', searchFields: 'nmae' } as any })); + const inProcess = await wireAnswer(engine.find(account.name, { search: 'harbour', searchFields: ['nmae'] })); + expect({ status: inProcess.status, code: inProcess.code }).toEqual({ status: door.status, code: door.code }); + expect(door).toMatchObject({ status: 400, code: 'INVALID_FIELD' }); + expect(reads).toEqual([]); + }); + + it('CONTROL: an object with no searchable field keeps its own refusal (#24284), naming that cause', async () => { + engine.registry.registerObject({ + name: 'link_28120', + label: 'Link', + fields: { id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true } }, + searchableFields: ['gone'], + } as any, 'test-package'); + const err: any = await engine.find('link_28120', { search: 'harbour', searchFields: ['nmae'] }).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 'INVALID_FIELD', param: 'search' }); + expect(err.message).toContain('has no searchable field'); + }); +}); diff --git a/packages/objectql/src/engine-search-empty-set.test.ts b/packages/objectql/src/engine-search-empty-set.test.ts index b592fb4053b..c5cf6be4d93 100644 --- a/packages/objectql/src/engine-search-empty-set.test.ts +++ b/packages/objectql/src/engine-search-empty-set.test.ts @@ -176,14 +176,14 @@ describe('[#24284] a search over an empty resolved search set is refused', () => } }); - it('control: an object with a stored text field still filters, and an override intersected to nothing falls back to its set', async () => { + it('control: an object with a stored text field still filters, and an override intersected to nothing is refused', async () => { expect((await engine.find('lib_book', { search: 'Dune' })).map((r) => r.id)).toEqual(['b1']); expect(await engine.find('lib_book', { search: 'zzzz-nothing' })).toEqual([]); expect(await engine.count('lib_book', { search: 'zzzz-nothing' })).toBe(0); - // A direct engine caller naming only fields outside the allowed set: the - // tolerant intersection falls back to the allowed set — a FILTERED - // answer, never every row. - expect((await engine.find('lib_book', { search: 'emma', searchFields: ['nope'] })).map((r) => r.id)).toEqual(['b2']); + // [#28120] A direct engine caller naming only fields outside the allowed + // set is refused, never answered over the whole allowed set. + await expect(engine.find('lib_book', { search: 'emma', searchFields: ['nope'] })) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields', field: 'nope' }); }); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 05eca3197d2..b60ba8c1828 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -5,7 +5,7 @@ import { QueryAST, QueryInput, HookContext, ServiceObject } from '@objectstack/s // [commit 74155c735] The defaulting node schema `fillQueryAstDefaults` runs author input // through — the declared `.default()` stays in `packages/spec`, the engine // only invokes it. -import { SortNodeSchema, FullTextSearchSchema } from '@objectstack/spec/data'; +import { SortNodeSchema, FullTextSearchSchema, resolveSearchFieldResolution } from '@objectstack/spec/data'; import { EngineQueryOptions, DataEngineInsertOptions, @@ -769,6 +769,53 @@ function refuseSearchOverrideNamingNoField(object: string, requested: unknown, p throw err; } +/** + * [#28120] …and an override that names no field the search can scan is refused + * `400 INVALID_FIELD`, the code the door answers the same value with + * (`assertSearchFieldsAreSearchable`, #4254): every name unknown, unsearchable, + * or withheld from the scan (`internal: true`, credential types), or no entry a + * field name at all. + * + * `resolveSearchFields` drops the names outside the allowed set and, when none + * is left, falls back to the whole set — the typo or the renamed field the + * caller wrote to narrow the search ran over the object's default fields + * instead, untold. Judged on the set the expansion resolves over (`allowed`), + * so this refuses exactly the overrides that fallback would have widened. An + * override naming at least one allowed field is left to the intersection (the + * engine's tolerance for the other names stands), and an empty `allowed` is + * left to the expansion's own refusal (#24284), which names that cause. + */ +function refuseSearchOverrideNamingNoSearchableField( + object: string, + requested: unknown, + param: string, + allowed: readonly string[], +): void { + const entries = typeof requested === 'string' + ? requested.split(',').map((s) => s.trim()).filter(Boolean) + : Array.isArray(requested) ? requested : undefined; + if (entries === undefined || entries.length === 0 || allowed.length === 0) return; + const allowSet = new Set(allowed); + if (entries.some((f) => typeof f === 'string' && allowSet.has(f))) return; + const names = entries.filter((f): f is string => typeof f === 'string'); + const err: any = new Error( + `ObjectQL search on '${object}' names no searchable field in '${param}': got ${JSON.stringify(requested)}. ` + + 'The query was not run: an override naming none of the fields the search scans was dropped, which ' + + "scans the object's default search fields. Name a searchable field, or leave the override out to " + + 'search the default fields.', + ); + err.status = 400; + err.httpStatus = 400; + err.code = 'INVALID_FIELD'; + if (names.length > 0) { + err.field = names[0]; + err.fields = names; + } + err.param = param; + err.object = object; + throw err; +} + /** * Rows per page for the autonumber seeding scan (#6249). This is a PAGE size, * not a cap: the walk pages until the scope is exhausted. The number is the one @@ -13453,8 +13500,9 @@ export class ObjectQL implements IObjectQLEngine { // data door instead (`assertSearchFieldsAreSearchable`, // `@objectstack/metadata-protocol`), the #4254 posture — an unhonoured // narrowing must be a 400, not a silent widening. A direct engine caller - // naming one has it intersected away here, the engine's existing tolerant - // posture for an unknown requested search field. + // naming one beside a searchable field has it intersected away here, the + // engine's existing tolerant posture for an unknown requested search + // field; naming only such fields is refused below (#28120). // [ADR-0100] …and never a `secret` / `password` column either, by the // collector the credential guards read: the auto-default already skips // those types, and an author-declared `searchableFields` naming one is @@ -13475,6 +13523,12 @@ export class ObjectQL implements IObjectQLEngine { // `displayNameField` is the deprecated alias (still honored). displayField: (schema as any).nameField ?? (schema as any).displayNameField, }; + // [#28120] An override naming none of the fields the expansion would + // scan is refused, never widened back to the whole set. + refuseSearchOverrideNamingNoSearchableField( + ast.object, requestedFields, (ast as any).searchFields != null ? 'searchFields' : 'search.fields', + resolveSearchFieldResolution(expandOptions).allowed, + ); // [#25672] …and never a column the CALLER may not query. The field-read // predicate guard (plugin-security) runs after this expansion and judges // the expanded `$or` as the caller's own predicate, so one hidden column @@ -13503,9 +13557,9 @@ export class ObjectQL implements IObjectQLEngine { // set the expansion itself resolved (no second resolver), so it holds // for every verb that reaches here (`find` / `findOne` / `aggregate` / // the count) and every door (REST, dispatcher, SDK, a hook's - // `ctx.api`). An explicit override intersected to nothing falls back - // to the allowed set inside `resolveSearchFields`, so it reaches this - // only when that set is empty too. [#25672] A set the caller's + // `ctx.api`). An explicit override intersected to nothing is refused + // before the expansion (#28120) unless the allowed set is empty too, so + // it reaches this only then. [#25672] A set the caller's // queryable fields withheld down to nothing gets the same answer: the // declared set expands without the narrowing, so the refusal says so. const withheld = queryableFields !== undefined && expandSearchToFilter(raw, expandOptions) !== null; diff --git a/packages/objectql/src/internal-field-positions.test.ts b/packages/objectql/src/internal-field-positions.test.ts index a65c2ec9309..ddac7bc87ca 100644 --- a/packages/objectql/src/internal-field-positions.test.ts +++ b/packages/objectql/src/internal-field-positions.test.ts @@ -141,12 +141,18 @@ describe('[#22646] a $search never scans an `internal: true` column', () => { expect(blob).not.toContain('token'); }); - it('an explicit searchFields naming the flagged column is intersected away at the engine (withheld)', async () => { + it('an explicit searchFields naming only the flagged column is refused at the engine, never scanned', async () => { await ctx.engine.insert('itest_vault', { name: 'alpha', prefix: 'osk_', rank: 1, token: 'h1' }, SYSTEM as any); - await ctx.engine.find('itest_vault', { search: 'h1', searchFields: ['token'] }, SYSTEM); + const reads = ctx.seenWhere.length; + // [#28120] The override names nothing scannable (the flagged column is + // withheld), so it is refused rather than widened to the default set. + await expect(ctx.engine.find('itest_vault', { search: 'h1', searchFields: ['token'] }, SYSTEM)) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields' }); + expect(ctx.seenWhere.length).toBe(reads); + // Named beside a scannable field, it is still intersected away: never a clause on `token`. + await ctx.engine.find('itest_vault', { search: 'h1', searchFields: ['name', 'token'] }, SYSTEM); const blob = JSON.stringify(lastSearchWhere() ?? null); - // The override narrowed to nothing scannable, so the engine falls back - // to the (internal-free) default set — never a clause on `token`. + expect(blob).toContain('name'); expect(blob).not.toContain('token'); }); diff --git a/packages/runtime/src/stored-metadata-reader-seam-search-override.test.ts b/packages/runtime/src/stored-metadata-reader-seam-search-override.test.ts new file mode 100644 index 00000000000..f1747337a7d --- /dev/null +++ b/packages/runtime/src/stored-metadata-reader-seam-search-override.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28120] An in-process search-field override on a stored-metadata table that + * names no field reaches the engine's refusal through the reader seam, rather + * than being narrowed into a search the door and the engine both refuse. + * + * The seam (`serveStoredMetadataReadsThrough`) narrows a DEFAULT `$search` on + * `sys_metadata` / `sys_metadata_history` to the searchable set minus the body + * and hash columns (`narrowStoredMetadataSearch`). It read an empty override as + * "names none", took that branch and wrote the narrowed set back over the + * caller's, so `searchFields: []` from a host handler was served where the REST + * door and the engine refuse it `400 VALIDATION_ERROR` (#27732). An override + * naming only unknown fields is refused by the engine `400 INVALID_FIELD` + * through the seam as well, rather than resolved to a default set. + * + * Driven through the seam over a REAL {@link ObjectQL} engine, with the real + * family definition registered and a driver double that records each read, so + * "refused before any driver read" and "which columns were scanned" are + * measured rather than assumed. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SysMetadataObject } from '@objectstack/metadata-core'; +import { ObjectQL } from '@objectstack/objectql'; +import { mapDataError } from '@objectstack/types'; +import { serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js'; + +/** Every column a `$or` of `$icontains` clauses names, in order, de-duplicated. */ +function scannedColumns(where: unknown): string[] { + const out = new Set(); + const walk = (node: unknown): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (!node || typeof node !== 'object') return; + for (const [k, v] of Object.entries(node as Record)) { + if (k.startsWith('$')) walk(v); + else if (v && typeof v === 'object' && '$icontains' in (v as Record)) out.add(k); + else walk(v); + } + }; + walk(where); + return [...out]; +} + +function makeDriver() { + const reads: Array<{ via: string; where: unknown }> = []; + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string, ast: any) { reads.push({ via: 'find', where: ast?.where }); return []; }, + async findOne(_o: string, ast: any) { reads.push({ via: 'findOne', where: ast?.where }); return null; }, + async count(_o: string, ast: any) { reads.push({ via: 'count', where: ast?.where }); return 0; }, + async create(_o: string, d: any) { return d; }, + async update(_o: string, _id: string, d: any) { return d; }, + async delete() { return true; }, + async bulkCreate(_o: string, r: any[]) { return r; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, reads }; +} + +/** The seam over the real engine: each repository verb reads through `engine`. */ +function servedApi(engine: ObjectQL): any { + const repo = (name: string) => ({ + find: (q: any) => engine.find(name, q), + findOne: (q: any) => engine.findOne(name, q), + count: (q: any) => engine.count(name, q), + }); + return serveStoredMetadataReadsThrough({ object: repo } as any, engine); +} + +/** The refusal as the REST door would answer it: `mapDataError`'s status and code. */ +async function wireAnswer(read: Promise) { + const err = await read.then( + () => { throw new Error('expected a refusal, got an answer'); }, + (e: unknown) => e, + ); + const mapped = mapDataError(err); + return { status: mapped.status, code: mapped.body.code, param: (err as any).param }; +} + +type Verb = 'find' | 'findOne' | 'count'; +const VERBS: readonly Verb[] = ['find', 'findOne', 'count']; + +describe('[#28120] the stored-metadata seam hands a no-field override to the engine\'s refusal', () => { + let api: any; + let reads: Array<{ via: string; where: unknown }>; + + beforeEach(async () => { + const engine = new ObjectQL(); + const made = makeDriver(); + reads = made.reads; + engine.registerDriver(made.driver, true); + await engine.init(); + engine.registry.registerObject(SysMetadataObject as any, 'test-package'); + api = servedApi(engine); + }); + + describe.each(VERBS)('%s', (verb) => { + it('searchFields: [] is refused 400 VALIDATION_ERROR, before any driver read', async () => { + const answer = await wireAnswer(api.object('sys_metadata')[verb]({ search: 'z', searchFields: [] })); + expect(answer).toEqual({ status: 400, code: 'VALIDATION_ERROR', param: 'searchFields' }); + expect(reads).toEqual([]); + }); + + it('a comma string naming no field, and an empty object-form list, are refused the same way', async () => { + expect(await wireAnswer(api.object('sys_metadata')[verb]({ search: 'z', searchFields: ' , ' }))) + .toEqual({ status: 400, code: 'VALIDATION_ERROR', param: 'searchFields' }); + expect(await wireAnswer(api.object('sys_metadata')[verb]({ search: { query: 'z', fields: [] } }))) + .toEqual({ status: 400, code: 'VALIDATION_ERROR', param: 'search.fields' }); + expect(reads).toEqual([]); + }); + + it('an override naming only unknown fields is refused 400 INVALID_FIELD, before any driver read', async () => { + const answer = await wireAnswer(api.object('sys_metadata')[verb]({ search: 'z', searchFields: ['no_such_field'] })); + expect(answer).toEqual({ status: 400, code: 'INVALID_FIELD', param: 'searchFields' }); + expect(reads).toEqual([]); + }); + + it('CONTROL: a named override still narrows to what it names', async () => { + await api.object('sys_metadata')[verb]({ search: 'z', searchFields: ['name'] }); + expect(reads.map((r) => scannedColumns(r.where))).toEqual([['name']]); + }); + + it('CONTROL: an absent override is still narrowed off the body and hash columns', async () => { + await api.object('sys_metadata')[verb]({ search: 'z' }); + expect(reads).toHaveLength(1); + const scanned = scannedColumns(reads[0]!.where); + expect(scanned).toContain('name'); + expect(scanned).not.toContain('metadata'); + expect(scanned).not.toContain('checksum'); + }); + }); +}); diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index d1ce37c991d..cdb3e64302a 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -254,11 +254,15 @@ export function resolveSearchFieldResolution( * present. * * TOLERANT by design — unknown requested names are dropped, and a request - * naming none of the allowed fields falls back to the full allowed set. This - * guards INTERNAL callers (hooks, flows, registry-less hosts) that never pass - * an ingress; the REST read path refuses those same inputs loudly BEFORE the - * engine sees them (`400 INVALID_FIELD`, #4254), exactly like the projection - * axis, whose engine-side `SELECT *` tolerance sits behind the #4226 gate. + * naming none of the allowed fields falls back to the full allowed set. The + * REST read path refuses those same inputs loudly BEFORE the engine sees them + * (`400 INVALID_FIELD`, #4254), exactly like the projection axis, whose + * engine-side `SELECT *` tolerance sits behind the #4226 gate. [#28120] The + * ObjectQL engine refuses the fallback's input too, for its in-process callers + * (hooks, flows, host handlers): an override naming none of the allowed fields + * is `400 INVALID_FIELD` there before this runs, so the fallback is reached + * only by a caller that resolves a search set without the engine. A request + * naming SOME allowed field still has the rest dropped here. */ export function resolveSearchFields(opts: SearchFieldResolutionOptions): string[] { const { allowed } = resolveSearchFieldResolution(opts);