diff --git a/.changeset/credential-column-filter-sort-guard.md b/.changeset/credential-column-filter-sort-guard.md new file mode 100644 index 00000000000..e7aa1c647aa --- /dev/null +++ b/.changeset/credential-column-filter-sort-guard.md @@ -0,0 +1,29 @@ +--- +'@objectstack/objectql': patch +--- + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (an accept-set narrowing), shipped as `patch`: nothing a metadata author writes changes (`Metadata-protocol: none`), so the level follows the kind, and the banner and the ADR-0087 disposition stay. + +Hardening (ADR-0100): the engine's credential-column guard now covers filter and sort keys, not only aggregates. A `password` or `secret` field was already refused as a group-by key or a min / max operand; the same refusal now covers every position that evaluates the stored value. + +**FROM.** For every caller except a system context, on `find`, `findOne`, `count`, `aggregate` and predicate `update` / `delete`, and so on every door that reaches them with a caller context (the REST data routes, the MCP bridges, flows, hooks' `ctx.api`, plugin routes): + +- A `where` / `filter` predicate on a `password` or `secret` field was evaluated, including a `{ "$field": … }` comparand, a per-aggregation `filter`, an `expand` entry's own `where` / `orderBy`, and a nested-relation condition on a related object's credential field. +- An `orderBy` key on such a field was admitted. +- A search scanned such a field when the object listed it in `searchableFields`. +- An import in `upsert` or `update` mode whose `matchFields` names such a field looked rows up by that field. + +**TO.** + +- Each of those filter and sort positions is refused `400 INVALID_FIELD`, the same ADR-0112 envelope as the aggregate refusal (with `field`, `object` and `param` naming the position), before any driver is asked. The group-by and min / max refusals are unchanged. +- A has-a-value check stays accepted: `{ "$null": … }`, `{ "$exists": … }`, `{ "$empty": … }`, or equality with `null`. +- A search never scans a credential column, even one an object lists in `searchableFields`. +- An import whose `matchFields` names such a field now meets the same refusal in its match lookup: each row that reaches the lookup is reported failed with that refusal and is not written. +- A system context is not bound, so the server's own credential lookups are unchanged. + +**Fix.** A caller that sorted or filtered on a `password` / `secret` field now receives `400 INVALID_FIELD`: sort or filter by another field, or use a has-a-value check. An import that matched on such a field matches on another field. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index f9aa290fe02..2155e8b610a 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -121,6 +121,15 @@ Reversible, encrypted-at-rest value (DB password, API key, token). Unlike `passw { name: 'api_key', label: 'API Key', type: 'secret' } ``` +**Querying a `password` or `secret` field.** Neither type is a filter, sort or aggregation key. +Only a has-a-value check is accepted on one: `{ "$null": true | false }`, `{ "$exists": … }`, +`{ "$empty": … }`, or equality with `null`. Any other operator in `where` or `filter` (equality +with a value, a range, `$startsWith` / `$contains`, `$in`, a `{ "$field": … }` comparand), an +`orderBy` entry, a group-by or a min / max over one is refused with `400 INVALID_FIELD` before +the query runs. That holds for every caller, administrators included. A search also never scans +such a column, even one listed in `searchableFields`. Only server-side code running as the +system context may match on the stored value (ADR-0100). + --- ## Rich Content Types diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 4d9cd55326c..fe68fdd7c2f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -121,6 +121,7 @@ bugs — it is the protection or the side effect that silently does not happen. | 17 | Approval snapshot payload redaction skipped, and so is the snapshot query guard and the reader-locale label overlay | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel — and a filter, sort or grouping by it on any read; and a request's `process_label` / `current_step_label` as stored, never in a locale the context carries, so nothing the platform reads back and writes again holds a reader's language. Lose: field-visibility redaction over approval payloads, the refusal of a query over the snapshot for a reader withheld a field of the objects it can reach, and the flow and step labels in the reader's language | `bindSnapshotRedactionMiddleware`, `bindSnapshotPredicateGuard`, `bindReaderLocaleLabelsMiddleware` | | 18 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `enforceAuth` | | 18b | Settings namespace read scope not applied | service-settings | Get: a read of `sys_setting`, `sys_setting_audit` or `sys_platform_setting` across every namespace, whatever each namespace's `readPermission` names — the settings service's own reads of its stores take this path. Lose: the per-namespace read gate the generic data API applies to every other caller, the same rule the settings routes enforce | `settingsReadDoorMiddleware` | +| 18c | **Credential-column filter / sort refusal not applied** — a `where`, `orderBy`, per-aggregation `filter` or `expand` entry naming a `password` / `secret` column is evaluated | objectql | Get: the server's own match on a stored credential value, a lookup that never returns it. Every other caller, administrators included, is refused `400 INVALID_FIELD` for anything but a has-a-value check (ADR-0100). Lose: nothing a wire caller reaches — `isSystem` is never set on an inbound request | `rejectCredentialEvaluation` | ### 2. Write pipeline and data integrity @@ -523,6 +524,7 @@ and CI refuses one that differs from what the tree produces. | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | 1 | 51 | | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict` | 1 | 55b | | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | 1 | 8 | +| `packages/objectql/src/credential-evaluation-guard.ts#rejectCredentialEvaluation` | 1 | 18c | | `packages/objectql/src/engine-delete-cascade.ts#recordReferenceCheckElevation` | 1 | 29 | | `packages/objectql/src/engine-driver-options.ts#buildDriverOptions` | 1 | 24 | | `packages/objectql/src/engine-execution-plumbing.ts#buildSession` | 1 | 63 | diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index 72962cc520f..164e77446fe 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -209,6 +209,15 @@ const CALLERS: Record = { kind: 'not-a-served-read', reason: "resolves a seed's references at install, under the system identity; serves nothing to a caller", }, + // ADR-0100: the credential-column guard's filter / sort half. + 'packages/objectql/src/credential-evaluation-guard.ts': { + kind: 'not-a-served-read', + reason: + "names the object an `expand` entry the caller wrote targets, only to read that object's DECLARED credential " + + 'fields from the registry and refuse (`INVALID_FIELD` / 400) a filter or sort key on one; it reads no record and ' + + "serves nothing of the target — the expanded read itself is the engine's `expandRelatedRecords`. Pinned by " + + '`packages/objectql/src/credential-evaluation-guard.test.ts` (the expand refusals assert the driver was never asked)', + }, // [#25522 cut 10] The write-path reference check, moved out of engine.ts. 'packages/objectql/src/engine-referential-integrity.ts': { kind: 'not-a-served-read', diff --git a/packages/objectql/src/credential-evaluation-guard.test.ts b/packages/objectql/src/credential-evaluation-guard.test.ts new file mode 100644 index 00000000000..24ee9d2ae90 --- /dev/null +++ b/packages/objectql/src/credential-evaluation-guard.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The credential-column guard's filter / sort half (ADR-0100), against a REAL + * {@link ObjectQL} engine over a minimal recording driver: every refusal is + * the ADR-0112 `INVALID_FIELD` / 400 envelope and leaves the driver unasked; + * a has-a-value check, a system context and every non-credential field are + * served as before. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { isCredentialPresenceCondition } from './credential-evaluation-guard.js'; +import type { EngineAggregateOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data'; + +function makeRecordingDriver() { + const rows = new Map[]>(); + const calls: string[] = []; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + if (v !== null && typeof v === 'object') continue; + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + calls.push(`find:${object}`); + const found = (rows.get(object) ?? []).filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? found.slice(0, ast.limit) : found; + return page.map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + calls.push(`findOne:${object}`); + const r = (rows.get(object) ?? []).find((x) => matches(x, ast?.where)); + return r ? { ...r } : null; + }, + async count(object: string, ast: any) { + calls.push(`count:${object}`); + return (rows.get(object) ?? []).filter((r) => matches(r, ast?.where)).length; + }, + async aggregate(object: string) { + calls.push(`aggregate:${object}`); + return [{ n: (rows.get(object) ?? []).length }]; + }, + async create(object: string, data: Record) { + const list = rows.get(object) ?? []; + const row = { id: `r${list.length + 1}`, ...data }; + list.push(row); + rows.set(object, list); + return { ...row }; + }, + async update(object: string, id: string, data: Record) { + calls.push(`update:${object}`); + return { id, ...data }; + }, + async updateMany(object: string) { calls.push(`updateMany:${object}`); return 0; }, + async delete(object: string) { calls.push(`delete:${object}`); return true; }, + async deleteMany(object: string) { calls.push(`deleteMany:${object}`); return 0; }, + async upsert(_o: string, data: Record) { return data; }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rows, calls }; +} + +const person = { + name: 'cg_person', label: 'Person', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + pin: { name: 'pin', label: 'PIN', type: 'password' as const }, + }, +}; + +const vault = { + name: 'cg_vault', label: 'Vault', + searchableFields: ['name', 'pin'], + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + pin: { name: 'pin', label: 'PIN', type: 'password' as const }, + token: { name: 'token', label: 'Token', type: 'secret' as const }, + holder: { name: 'holder', label: 'Holder', type: 'lookup' as const, reference: 'cg_person' }, + }, +}; + +const USER: EngineQueryOptions['context'] = { userId: 'u1' }; +const SYSTEM: EngineQueryOptions['context'] = { isSystem: true }; + +async function buildEngine() { + const engine = new ObjectQL(); + const recording = makeRecordingDriver(); + engine.registerDriver(recording.driver, true); + await engine.init(); + engine.registry.registerObject(person as any); + engine.registry.registerObject(vault as any); + recording.rows.set('cg_person', [{ id: 'p1', name: 'Pat', pin: '1234' }]); + recording.rows.set('cg_vault', [{ id: 'v1', name: 'alpha', pin: 'p@ssw0rd!', token: 'secret:h1', holder: 'p1' }]); + return { engine, ...recording }; +} + +/** The ADR-0112 envelope the refusal carries, asserted on the thrown error. */ +async function expectRefused(run: () => Promise, field: string, param: string) { + let thrown: any; + try { await run(); } catch (e) { thrown = e; } + expect(thrown, 'the call was refused').toBeDefined(); + expect(thrown.code).toBe('INVALID_FIELD'); + expect(thrown.status).toBe(400); + expect(thrown.httpStatus).toBe(400); + expect(thrown.field).toBe(field); + expect(thrown.param).toBe(param); +} + +describe('ADR-0100 credential-column guard — filter and sort keys', () => { + let ctx: Awaited>; + beforeEach(async () => { ctx = await buildEngine(); }); + + const refusedWheres: Array<[string, EngineQueryOptions['where'], string]> = [ + ['equality', { pin: 'p@ssw0rd!' }, 'pin'], + ['$eq with a value', { pin: { $eq: 'p@ssw0rd!' } }, 'pin'], + ['$ne with a value', { pin: { $ne: 'x' } }, 'pin'], + ['range', { pin: { $gt: 'o' } }, 'pin'], + ['$startsWith', { pin: { $startsWith: 'p@' } }, 'pin'], + ['$contains', { pin: { $contains: 'ssw' } }, 'pin'], + ['$in', { pin: { $in: ['a', 'b'] } }, 'pin'], + ['a secret column', { token: { $startsWith: 'secret:' } }, 'token'], + ['inside $or', { $or: [{ name: 'alpha' }, { pin: 'x' }] }, 'pin'], + ['inside $not', { $not: { pin: 'x' } }, 'pin'], + ['$not over a value operator', { pin: { $not: { $eq: 'x' } } }, 'pin'], + ['a cross-field comparand', { name: { $eq: { $field: 'pin' } } }, 'pin'], + ['the array form', [['pin', '=', 'p@ssw0rd!']], 'pin'], + ]; + + for (const [label, where, field] of refusedWheres) { + it(`find refuses ${label} on a credential column, before the driver is asked`, async () => { + await expectRefused(() => ctx.engine.find('cg_vault', { where, context: USER }), field, 'where'); + expect(ctx.calls).toEqual([]); + }); + } + + it('a context-less engine call is judged like any non-system caller', async () => { + await expectRefused(() => ctx.engine.find('cg_vault', { where: { pin: 'x' } }), 'pin', 'where'); + }); + + it('refuses orderBy on a password and on a secret column', async () => { + await expectRefused( + () => ctx.engine.find('cg_vault', { orderBy: [{ field: 'pin', order: 'asc' }], context: USER }), 'pin', 'orderBy', + ); + await expectRefused( + () => ctx.engine.find('cg_vault', { orderBy: [{ field: 'token', order: 'desc' }], context: USER }), 'token', 'orderBy', + ); + expect(ctx.calls).toEqual([]); + }); + + it('refuses on findOne, count, aggregate (where and per-aggregation filter), update and delete', async () => { + await expectRefused(() => ctx.engine.findOne('cg_vault', { where: { pin: 'x' }, context: USER }), 'pin', 'where'); + await expectRefused(() => ctx.engine.count('cg_vault', { where: { pin: 'x' }, context: USER }), 'pin', 'where'); + await expectRefused( + () => ctx.engine.aggregate('cg_vault', { where: { pin: 'x' }, aggregations: [{ function: 'count', alias: 'n' }] }, { context: USER }), + 'pin', 'where', + ); + await expectRefused( + () => ctx.engine.aggregate('cg_vault', { + aggregations: [{ function: 'count', alias: 'n', filter: { pin: { $gt: 'o' } } }], + } satisfies EngineAggregateOptions, { context: USER }), + 'pin', 'aggregations[0].filter', + ); + await expectRefused( + () => ctx.engine.update('cg_vault', { name: 'z' }, { where: { pin: 'x' }, multi: true, context: USER } satisfies EngineUpdateOptions), 'pin', 'where', + ); + await expectRefused( + () => ctx.engine.delete('cg_vault', { where: { pin: 'x' }, multi: true, context: USER } satisfies EngineDeleteOptions), 'pin', 'where', + ); + expect(ctx.calls).toEqual([]); + }); + + it('refuses a credential column in an expand entry, judged against the target object', async () => { + await expectRefused( + () => ctx.engine.find('cg_vault', { expand: { holder: { object: 'cg_person', where: { pin: '1234' } } }, context: USER } satisfies EngineQueryOptions), + 'pin', 'expand.holder.where', + ); + await expectRefused( + () => ctx.engine.find('cg_vault', { expand: { holder: { object: 'cg_person', orderBy: [{ field: 'pin' }] } }, context: USER } satisfies EngineQueryOptions), + 'pin', 'expand.holder.orderBy', + ); + expect(ctx.calls).toEqual([]); + }); + + it('refuses a nested-relation condition on the related object’s credential column', async () => { + await expectRefused( + () => ctx.engine.find('cg_vault', { where: { holder: { pin: '1234' } }, context: USER }), 'pin', 'where', + ); + }); + + const presence: Array<[string, EngineQueryOptions['where']]> = [ + ['bare null', { pin: null }], + ['$null: true', { pin: { $null: true } }], + ['$null: false', { pin: { $null: false } }], + ['$exists', { pin: { $exists: true } }], + ['$empty', { pin: { $empty: false } }], + ['$eq: null', { pin: { $eq: null } }], + ['$ne: null', { token: { $ne: null } }], + ['$not over a presence check', { pin: { $not: { $null: true } } }], + ]; + for (const [label, where] of presence) { + it(`serves a has-a-value check (${label})`, async () => { + await expect(ctx.engine.find('cg_vault', { where, context: USER })).resolves.toBeInstanceOf(Array); + expect(ctx.calls).toContain('find:cg_vault'); + }); + } + + it('serves a system context unchanged — the server’s own match on a credential column', async () => { + const rows = await ctx.engine.find('cg_vault', { where: { pin: 'p@ssw0rd!' }, context: SYSTEM }); + expect(rows.map((r: any) => r.id)).toEqual(['v1']); + await expect(ctx.engine.find('cg_vault', { orderBy: [{ field: 'pin', order: 'asc' }], context: SYSTEM })).resolves.toHaveLength(1); + await expect(ctx.engine.count('cg_vault', { where: { pin: 'p@ssw0rd!' }, context: SYSTEM })).resolves.toBe(1); + }); + + it('leaves every non-credential field unaffected', async () => { + const rows = await ctx.engine.find('cg_vault', { + where: { name: 'alpha', holder: 'p1' }, orderBy: [{ field: 'name', order: 'asc' }], context: USER, + }); + expect(rows.map((r: any) => r.id)).toEqual(['v1']); + await expect(ctx.engine.count('cg_vault', { where: { name: 'alpha' }, context: USER })).resolves.toBe(1); + }); + + it('a search never scans a credential column, even one the author listed as searchable', async () => { + let seenWhere: unknown; + const original = ctx.driver.find; + ctx.driver.find = async (object: string, ast: any) => { seenWhere = ast?.where; return original.call(ctx.driver, object, ast); }; + await ctx.engine.find('cg_vault', { search: 'p@ss', context: USER }); + expect(JSON.stringify(seenWhere)).toContain('"name"'); + expect(JSON.stringify(seenWhere)).not.toContain('"pin"'); + }); +}); + +describe('isCredentialPresenceCondition', () => { + it('accepts only presence shapes', () => { + expect(isCredentialPresenceCondition(null)).toBe(true); + expect(isCredentialPresenceCondition({ $null: true })).toBe(true); + expect(isCredentialPresenceCondition({ $ne: null })).toBe(true); + expect(isCredentialPresenceCondition({})).toBe(false); + expect(isCredentialPresenceCondition('x')).toBe(false); + expect(isCredentialPresenceCondition({ $null: 'yes' })).toBe(false); + expect(isCredentialPresenceCondition({ $null: true, $eq: 'x' })).toBe(false); + expect(isCredentialPresenceCondition({ $exists: true, $empty: false })).toBe(true); + }); +}); diff --git a/packages/objectql/src/credential-evaluation-guard.ts b/packages/objectql/src/credential-evaluation-guard.ts new file mode 100644 index 00000000000..3d38a49b3d6 --- /dev/null +++ b/packages/objectql/src/credential-evaluation-guard.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The credential-column guard's FILTER and SORT half (ADR-0100) — the + * counterpart of `rejectCredentialAggregation` (`engine-count-aggregate.ts`), + * which refuses a GROUP BY / MIN / MAX over the same columns. + * + * A `secret` or `password` field never leaves the engine's generic read path + * as its stored value: `secret` holds an opaque `sys_secret` ref and is served + * as the mask, a generic `password` is plaintext at rest and served as the + * mask. Withholding the value from the ROW is not enough on its own: a + * predicate or an order key evaluates the stored value, so a read that names + * such a column in `where` (or a cross-field `{ $field }` comparand) or in + * `orderBy` has its answer shaped by a value the caller is never shown. So the + * engine REFUSES those positions before any driver is asked, in the aggregate + * guard's envelope — ADR-0112 `INVALID_FIELD` / 400, located at the object, + * the field(s) and the position. + * + * ## The one accepted shape: a has-a-value check + * + * Whether a credential is SET is information every read already serves (the + * mask for a set value, `null` for an unset one), so a presence check reveals + * nothing the row does not: `{ f: null }`, `{ f: { $null: bool } }`, + * `{ f: { $exists: bool } }`, `{ f: { $empty: bool } }`, `{ f: { $eq: null } }`, + * `{ f: { $ne: null } }`, and `$not` over any of those. Every other operator + * and operand on the column is refused. + * + * ## Who it binds + * + * Every caller except a SYSTEM context (`context.isSystem === true`). The + * server's own credential consumers match on such columns without ever + * returning them, and do so as system; the security/sharing middlewares and + * `beforeFind` hooks inject their server-authored filters into the AST AFTER + * this guard has judged the caller's own query, so they are never judged here. + * Unlike the aggregate guard — which has no legitimate caller at all — a + * predicate has one, and that caller is the server. + * + * ## Positions + * + * - `where` (after the array-form lowering, so `[field, op, value]` is the + * object form here), every logical branch (`$and` / `$or` / `$not`); + * - a cross-field comparand `{ $field: }` anywhere in a value; + * - `orderBy` entries (string or `{ field }`); + * - each `aggregations[i].filter`; + * - each `expand` entry's own `where` / `orderBy`, at every depth, judged + * against that entry's TARGET object. + * + * A NESTED-RELATION condition (`{ : { : … } }`) is not + * walked here: the engine lowers it by reading the related object with the + * caller's context (`ObjectQL.lowerRelationConditions`), and that read passes + * through this guard against the related object's own fields. + * + * The collector is {@link collectCredentialFields} — type-keyed and + * exemption-free, the one the aggregate guard reads, so the two halves cannot + * drift. The `internal: true` flag's evaluate refusal stays where it was + * decided, at the generic data door (`@objectstack/metadata-protocol`, + * `assertNoInternalFieldEvaluated`), because its server-side consumers do not + * all run as system. + */ + +import { StandardErrorCode } from '@objectstack/spec/api'; +import { REFERENCE_VALUE_TYPES, referenceTargetOf } from '@objectstack/spec/data'; +import { collectCredentialFields } from './secret-fields.js'; + +/** The slice of a read the guard judges. Every member is optional. */ +export interface CredentialEvaluationQuery { + where?: unknown; + orderBy?: unknown; + expand?: unknown; + aggregations?: unknown; +} + +/** The slice of an execution context the guard reads. */ +interface CredentialEvaluationContext { + isSystem?: unknown; +} + +/** A backstop against a self-referential in-process value. */ +const CREDENTIAL_WALK_DEPTH = 8; + +/** Presence operators whose operand is a boolean. */ +const PRESENCE_BOOLEAN_OPERATORS: ReadonlySet = new Set(['$null', '$exists', '$empty']); +/** Equality operators accepted only with a `null` operand. */ +const NULL_EQUALITY_OPERATORS: ReadonlySet = new Set(['$eq', '$ne']); + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function headSegment(path: string): string { + const dot = path.indexOf('.'); + return dot === -1 ? path : path.slice(0, dot); +} + +/** + * Whether `value`, written as the condition on a credential column, asks only + * whether a value is set. See the module header for the accepted shapes. + */ +export function isCredentialPresenceCondition(value: unknown): boolean { + if (value === null) return true; + if (!isPlainRecord(value)) return false; + const entries = Object.entries(value); + if (entries.length === 0) return false; + return entries.every(([op, operand]) => + (PRESENCE_BOOLEAN_OPERATORS.has(op) && typeof operand === 'boolean') + || (NULL_EQUALITY_OPERATORS.has(op) && operand === null) + || (op === '$not' && isCredentialPresenceCondition(operand))); +} + +/** A condition value that is itself a filter over a related object's fields. */ +function isRelationCondition(value: unknown): boolean { + return isPlainRecord(value) && Object.keys(value).some((k) => !k.startsWith('$')); +} + +function collectComparandHits(node: unknown, credential: ReadonlySet, hits: Set, depth: number): void { + if (node === null || typeof node !== 'object' || depth > CREDENTIAL_WALK_DEPTH) return; + if (Array.isArray(node)) { + for (const member of node) collectComparandHits(member, credential, hits, depth + 1); + return; + } + const record = node as Record; + if (typeof record.$field === 'string') { + const head = headSegment(record.$field); + if (credential.has(head)) hits.add(head); + } + for (const value of Object.values(record)) collectComparandHits(value, credential, hits, depth + 1); +} + +function collectConditionHits(node: unknown, credential: ReadonlySet, hits: Set, depth: number): void { + if (node === null || typeof node !== 'object' || depth > CREDENTIAL_WALK_DEPTH) return; + if (Array.isArray(node)) { + for (const member of node) collectConditionHits(member, credential, hits, depth + 1); + return; + } + for (const [key, value] of Object.entries(node as Record)) { + if (key.startsWith('$')) { + collectConditionHits(value, credential, hits, depth + 1); + continue; + } + const head = headSegment(key); + if (credential.has(head)) { + if (key !== head || !isCredentialPresenceCondition(value)) hits.add(head); + continue; + } + // Judged by the related object's own read (see the module header). + if (isRelationCondition(value)) continue; + collectComparandHits(value, credential, hits, depth + 1); + } +} + +function orderByNames(orderBy: unknown): string[] { + if (!Array.isArray(orderBy)) return []; + const out: string[] = []; + for (const entry of orderBy) { + const field = typeof entry === 'string' ? entry : isPlainRecord(entry) ? entry.field : undefined; + if (typeof field === 'string' && field.length > 0) out.push(headSegment(field.replace(/^-/, ''))); + } + return out; +} + +function refusal(object: string, operation: string, fields: string[], param: string, types: Record): Error { + const first = fields[0] as string; + const sort = param.endsWith('orderBy'); + const named = fields.map((f) => `'${f}' (${types[f] ?? 'credential'})`).join(', '); + const err = new Error( + `Cannot ${sort ? 'sort' : 'filter'} '${object}' by ${named} at '${param}' in ${operation}(): the query was not run. ` + + `${fields.length > 1 ? 'These are credential fields' : 'This is a credential field'} whose stored value is ` + + 'withheld on every read — served as the mask, never as the value (ADR-0100) — so it is not evaluated ' + + (sort + ? 'as a sort key. Sort by another field.' + : 'as a filter. Only a has-a-value check is accepted on it: { "$null": true | false }, ' + + '{ "$exists": true | false }, { "$empty": true | false }, or equality with null. Filter on another field.'), + ) as Error & { + code?: string; status?: number; httpStatus?: number; field?: string; fields?: string[]; object?: string; param?: string; + }; + // ADR-0112 envelope, the aggregate guard's: `status` for the HTTP doors, + // `httpStatus` for a consumer holding the thrown error. + err.code = StandardErrorCode.enum.INVALID_FIELD; + err.status = 400; + err.httpStatus = 400; + err.field = first; + err.fields = fields; + err.object = object; + err.param = param; + return err; +} + +function credentialTypes(schema: unknown): { names: ReadonlySet; types: Record } { + const names = collectCredentialFields(schema as never); + const fields = (schema as { fields?: Record } | undefined)?.fields ?? {}; + const types: Record = {}; + for (const n of names) types[n] = String(fields[n]?.type ?? 'credential'); + return { names: new Set(names), types }; +} + +function judge( + object: string, + operation: string, + query: CredentialEvaluationQuery, + schemaOf: (name: string) => unknown, + prefix: string, + depth: number, +): void { + if (depth > CREDENTIAL_WALK_DEPTH) return; + const schema = schemaOf(object); + const { names, types } = credentialTypes(schema); + const at = (slot: string) => (prefix ? `${prefix}.${slot}` : slot); + + if (names.size > 0) { + const whereHits = new Set(); + collectConditionHits(query.where, names, whereHits, 0); + if (whereHits.size > 0) throw refusal(object, operation, [...whereHits], at('where'), types); + + const sortHits = orderByNames(query.orderBy).filter((n) => names.has(n)); + if (sortHits.length > 0) throw refusal(object, operation, [...new Set(sortHits)], at('orderBy'), types); + + if (Array.isArray(query.aggregations)) { + query.aggregations.forEach((agg, i) => { + const filterHits = new Set(); + collectConditionHits(isPlainRecord(agg) ? agg.filter : undefined, names, filterHits, 0); + if (filterHits.size > 0) throw refusal(object, operation, [...filterHits], at(`aggregations[${i}].filter`), types); + }); + } + } + + if (!isPlainRecord(query.expand)) return; + const fields = (schema as { fields?: Record } | undefined)?.fields ?? {}; + for (const [rel, entry] of Object.entries(query.expand)) { + if (!isPlainRecord(entry)) continue; + const def = fields[rel]; + const target = def != null && REFERENCE_VALUE_TYPES.has(String(def.type)) ? referenceTargetOf(def) : undefined; + if (!target) continue; + judge(target, operation, entry as CredentialEvaluationQuery, schemaOf, at(`expand.${rel}`), depth + 1); + } +} + +/** + * Refuse a read or a predicate write whose `where`, `orderBy`, per-aggregation + * `filter` or `expand` tree evaluates a `secret` / `password` column, for every + * caller but a system context. See the module header for the contract. + * + * Called by `find`, `findOne`, `count`, `aggregate`, `update` and `delete`, + * right after the `where` array-form lowering and before any `search` term is + * expanded, any token resolved, any middleware run or any driver asked. + */ +export function rejectCredentialEvaluation( + object: string, + operation: string, + query: CredentialEvaluationQuery | undefined, + context: CredentialEvaluationContext | undefined, + schemaOf: (name: string) => unknown, +): void { + if (!query || context?.isSystem === true) return; + judge(object, operation, query, schemaOf, '', 0); +} diff --git a/packages/objectql/src/engine-count-aggregate.ts b/packages/objectql/src/engine-count-aggregate.ts index 89d75d64975..20718ca9ee8 100644 --- a/packages/objectql/src/engine-count-aggregate.ts +++ b/packages/objectql/src/engine-count-aggregate.ts @@ -40,8 +40,9 @@ * this module imports `engine.ts`). Nothing here runs at module evaluation, * only declarations, and the helpers are read only when a body runs, after * both modules have evaluated. `engine.ts` imports this module right after - * `./secret-fields.js`, the one other module this one imports, so the - * evaluation order of every module is what it was; the + * `./secret-fields.js`, and the two other modules this one imports + * (`./secret-fields.js`, `./credential-evaluation-guard.js`) import nothing + * from `engine.ts`, so the evaluation order of every module is what it was; the * `@objectstack/spec/api` import that left `engine.ts` with * `rejectCredentialAggregation` was not that package's first load. The * published build bundles both files into one, where the cycle does not @@ -56,6 +57,7 @@ import { collectInternalReadFields, collectCredentialFields, } from './secret-fields.js'; +import { rejectCredentialEvaluation } from './credential-evaluation-guard.js'; import { ENGINE_COUNT_OPTION_KEYS, ENGINE_WHERE_SLOTS, @@ -101,6 +103,8 @@ export async function count( query = foldEngineOptionAliases(object, 'count', query, ENGINE_WHERE_SLOTS); rejectUnknownEngineOptions(object, 'count', query, ENGINE_COUNT_OPTION_KEYS); query = lowerWhereFilterArray(object, 'count', query, this._registry.getObject(object), this.relatedSchemaOf); + // [ADR-0100] Same credential-column refusal as `find`, same position. + rejectCredentialEvaluation(object, 'count', query, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); // [#22790] ADR-0061 `search` → the rows are searched before they are // counted, by the one expander `find` runs, at the same point in the // sequence — so the count is the number of rows `find` returns for the bag. diff --git a/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts b/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts index 47b5c5d3222..72d19a8893c 100644 --- a/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts +++ b/packages/objectql/src/engine-number-comparand-declared-type-door.test.ts @@ -73,6 +73,19 @@ import { narrowNumberComparands, } from './number-comparand-declared-type-door.js'; +/** + * This door runs first, inside `lowerWhereFilterArray`. The credential-column + * guard (ADR-0100, `credential-evaluation-guard.ts`) runs after it and answers + * the rows this door passes: it refuses a non-system caller's predicate on a + * `password` / `secret` column. This door's verdict does not depend on the + * caller, so the cells over those two types are driven as a SYSTEM caller — + * the one caller the guard serves — so a cell this door passes still reaches + * the driver, and the door is still the thing measured. + */ +const CREDENTIAL_COLUMN_TYPES: ReadonlySet = new Set(['password', 'secret']); +const readAs = (declaredType: string, where: EngineQueryOptions['where']): EngineQueryOptions => + (CREDENTIAL_COLUMN_TYPES.has(declaredType) ? { where, context: { isSystem: true } } : { where }); + const OBJECT = NUMBER_COMPARAND_DOOR_FIXTURE_OBJECT; /** @@ -237,7 +250,7 @@ describe('[#20351] the number-comparand declared-type door at the engine collect for (const c of PASSES) { reads.length = 0; const filter = c.filter(); - await expect(engine.find(OBJECT, { where: filter }), c.name).resolves.toBeDefined(); + await expect(engine.find(OBJECT, readAs(c.declaredType, filter)), c.name).resolves.toBeDefined(); expect(reads, `${c.name}: the driver must have been read`).toHaveLength(1); expect(reads[0]?.ast?.where, `${c.name}: the filter must reach the driver unchanged`).toEqual(lowered(filter)); } diff --git a/packages/objectql/src/engine-text-operator-declared-type-door.test.ts b/packages/objectql/src/engine-text-operator-declared-type-door.test.ts index 38d40a47897..2685b9f1bc3 100644 --- a/packages/objectql/src/engine-text-operator-declared-type-door.test.ts +++ b/packages/objectql/src/engine-text-operator-declared-type-door.test.ts @@ -80,6 +80,19 @@ import { ObjectQL } from './engine.js'; import { applyHaving } from './having-filter.js'; import { findTextOperatorOverNonTextField } from './text-operator-declared-type-door.js'; +/** + * This door runs first, inside `lowerWhereFilterArray`. The credential-column + * guard (ADR-0100, `credential-evaluation-guard.ts`) runs after it and answers + * the rows this door passes: it refuses a non-system caller's predicate on a + * `password` / `secret` column. This door's verdict does not depend on the + * caller, so the cells over those two types are driven as a SYSTEM caller — + * the one caller the guard serves — so a cell this door passes still reaches + * the driver, and the door is still the thing measured. + */ +const CREDENTIAL_COLUMN_TYPES: ReadonlySet = new Set(['password', 'secret']); +const readAs = (declaredType: string, where: EngineQueryOptions['where']): EngineQueryOptions => + (CREDENTIAL_COLUMN_TYPES.has(declaredType) ? { where, context: { isSystem: true } } : { where }); + const OBJECT = TEXT_OPERATOR_DOOR_FIXTURE_OBJECT; /** @@ -199,7 +212,7 @@ describe('[#15773] the text-operator declared-type door at the engine collection for (const c of PASSES) { reads.length = 0; const filter = c.filter(); - await expect(engine.find(OBJECT, { where: filter }), c.name).resolves.toBeDefined(); + await expect(engine.find(OBJECT, readAs(c.declaredType, filter)), c.name).resolves.toBeDefined(); expect(reads, `${c.name}: the driver must have been read`).toHaveLength(1); expect(reads[0]?.ast?.where, `${c.name}: the filter must reach the driver unchanged`).toEqual(lowered(filter)); } @@ -248,7 +261,7 @@ describe('[#15773] the text-operator declared-type door at the engine collection for (const type of row.types) { reads.length = 0; const field = `f_${type}`; - const err = await refusalOf(engine.find(OBJECT, { where: { [field]: { $contains: '5' } } })); + const err = await refusalOf(engine.find(OBJECT, readAs(type, { [field]: { $contains: '5' } }))); if (row.verdict === 'door-refusal') { expect(err, `${row.name}/${type}: must be refused — ${row.note}`).not.toBeNull(); expect({ code: err!.code, status: err!.status }, `${row.name}/${type}`) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 932043014e4..1a7e06f32d2 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -301,6 +301,9 @@ import { // (`rejectCredentialAggregation`); the methods of the same names below hand // each body the members it reads (`CountAggregateHost`). import { count, rejectCredentialAggregation } from './engine-count-aggregate.js'; +// ADR-0100: the credential-column guard's filter / sort half, beside the +// aggregate half above — one collector, one envelope. +import { rejectCredentialEvaluation } from './credential-evaluation-guard.js'; import { assertGroupByNamesNoJsonStoredField } from './group-by-structured-json-door.js'; import { assertProjectionNamesAField } from './empty-projection-door.js'; import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js'; @@ -13315,7 +13318,12 @@ export class ObjectQL implements IObjectQLEngine { // 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. - const internalFields = collectInternalReadFields(schema); + // [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 + // withheld here the same way, so a search term is never evaluated over + // a value the read path masks. + const internalFields = [...new Set([...collectInternalReadFields(schema), ...collectCredentialFields(schema)])]; const scanFields = internalFields.length === 0 ? (schema.fields as any) : Object.fromEntries( @@ -13578,6 +13586,11 @@ export class ObjectQL implements IObjectQLEngine { // schemas declare are filled here, before anything downstream reads the AST. const { context: _findContext, ...findQuery } = query ?? {}; const ast: QueryAST = { ...this.fillQueryAstDefaults(findQuery), object }; + // [ADR-0100] A `secret` / `password` column is never a filter or sort key + // for a non-system caller — judged on the caller's own `where` / `orderBy` + // / `expand`, before the search term is expanded into `where` and before + // the middlewares inject their server-authored filters. + rejectCredentialEvaluation(object, 'find', ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); // Plan formula projection: rewrite ast.fields to drop virtual formula // names and inject their dependencies, so the driver returns the raw @@ -13903,6 +13916,8 @@ export class ObjectQL implements IObjectQLEngine { // [commit 74155c735] And the same default-filling as find(), for the same reason. const { context: _findOneContext, ...findOneQuery } = query ?? {}; const ast: QueryAST = { ...this.fillQueryAstDefaults(findOneQuery), object: objectName, limit: 1 }; + // [ADR-0100] Same credential-column refusal as `find`, same position. + rejectCredentialEvaluation(objectName, 'findOne', ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); // Plan formula projection (same as find): rewrite ast.fields so the driver // returns the raw dependency fields, then evaluate formulas after fetch. @@ -15855,6 +15870,9 @@ export class ObjectQL implements IObjectQLEngine { // array that read is `undefined` whatever the caller wrote, so an // `update({ where: [['id','=',x]] })` used to route to the multi-row path. options = lowerWhereFilterArray(object, 'update', options, this._registry.getObject(object), this.relatedSchemaOf); + // [ADR-0100] A predicate write is judged like a read: a non-system + // caller's `where` evaluates no `secret` / `password` column. + rejectCredentialEvaluation(object, 'update', options, options?.context, this.relatedSchemaOf); // Expand `{filter-placeholder}` values BEFORE the id is extracted (#3810). // The read path resolves them; without the same call here the SAME filter @@ -19434,6 +19452,8 @@ export class ObjectQL implements IObjectQLEngine { // [#5158] Same ordering reason as update(): the dispatch decision below // reads `where.id`, which an unlowered array never carries. options = lowerWhereFilterArray(object, 'delete', options, this._registry.getObject(object), this.relatedSchemaOf); + // [ADR-0100] Same credential-column refusal as update(). + rejectCredentialEvaluation(object, 'delete', options, options?.context, this.relatedSchemaOf); // Expand `{filter-placeholder}` values before the id is extracted — same // reasoning as update() above (#3810). @@ -20140,6 +20160,10 @@ export class ObjectQL implements IObjectQLEngine { query = foldEngineOptionAliases(object, 'aggregate', query, ENGINE_WHERE_SLOTS); rejectUnknownEngineOptions(object, 'aggregate', query, ENGINE_AGGREGATE_OPTION_KEYS); query = lowerWhereFilterArray(object, 'aggregate', query, this._registry.getObject(object), this.relatedSchemaOf); + // [ADR-0100] The filter half of the credential-column guard — `where` + // and every `aggregations[i].filter` — before the search term joins + // `where`; the aggregate half (`rejectCredentialAggregation`) follows. + rejectCredentialEvaluation(object, 'aggregate', query, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); // ADR-0061 `search` → the rows are searched BEFORE they are grouped, by // the one expander `find` runs, at the same point in the sequence (after // the `where` doors above, before the AST is built and tokens resolve). diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 5cf6ba41866..dc975b53587 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -256,6 +256,10 @@ const READERS: Record = { disposition: 'target-by-id', why: 'the ids read off the rows in hand load the TARGET rows; a federated row carries none', }, + 'credential-evaluation-guard.ts#judge :: referenceTargetOf()': { + disposition: 'caller-predicate', + why: "names the object an expand entry the caller wrote is judged against, by its declared fields; it reads no row", + }, 'record-title.ts#resolveRelatedTitleTarget :: referenceTargetOf()': { disposition: 'target-by-id', why: "resolves which object a related record's title is read from", diff --git a/packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts b/packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts index b00189ec01e..109e3d299a5 100644 --- a/packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts +++ b/packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts @@ -14,6 +14,10 @@ // acceptance[3], negative[1] — with NO crypto provider, a non-empty secret // write is refused and nothing is persisted; a `password` write needs no // provider. +// (ADR-0100, beside acceptance[4]) — the same credential columns as filter +// and sort keys: refused 400 INVALID_FIELD for a signed-in caller, a +// has-a-value check served; an `internal: true` column likewise at the +// door. The seeded admin's sign-in in each `beforeAll` is the login leg. // // ## Two boots // @@ -118,6 +122,46 @@ describe('records-forms.encrypted-field-behavior — the showcase field zoo over const control = await call('POST', `/data/${ZOO}/query`, { groupBy: ['name'], aggregations: [{ function: 'count', alias: 'n' }] }); expect(control.status, control.text).toBe(200); }); + + // ADR-0100, the filter / sort half of the credential-column guard: the + // aggregate refusal above extended to filter and sort keys. A signed-in admin + // is not a system context, so every one of these is refused before the + // driver; a has-a-value check and every other column are served. + it('a filter or sort key on a credential column is refused; a has-a-value check is served', async () => { + const created = await call('POST', `/data/${ZOO}`, { + name: 'efb-pred', f_master_detail: projectId, f_secret: SECRET, f_password: PASSWORD, + }); + expect(created.status, created.text).toBe(201); + + const refused: Array<[string, string, string, unknown?]> = [ + ['where equality', 'POST', `/data/${ZOO}/query`, { where: { f_password: PASSWORD } }], + ['where range', 'POST', `/data/${ZOO}/query`, { where: { f_password: { $gt: 'a' } } }], + ['where $startsWith', 'POST', `/data/${ZOO}/query`, { where: { f_password: { $startsWith: 'p' } } }], + ['where $contains', 'POST', `/data/${ZOO}/query`, { where: { f_password: { $contains: 's' } } }], + ['where on the secret column', 'POST', `/data/${ZOO}/query`, { where: { f_secret: { $startsWith: 'secret:' } } }], + ['orderBy the password column', 'POST', `/data/${ZOO}/query`, { orderBy: [{ field: 'f_password', order: 'asc' }] }], + ['list filter param', 'GET', `/data/${ZOO}?filter=${encodeURIComponent(JSON.stringify(['f_password', '=', PASSWORD]))}`], + ]; + for (const [label, method, path, body] of refused) { + const res = await call(method, path, body); + expect(res.status, `${label}: ${res.text}`).toBe(400); + expect(res.json.code, `${label}: ${res.text}`).toBe('INVALID_FIELD'); + expect(res.text, label).not.toContain(PASSWORD); + expect(res.text, label).not.toContain(SECRET); + } + + const presence = await call('POST', `/data/${ZOO}/query`, { where: { name: 'efb-pred', f_password: { $null: false } } }); + expect(presence.status, presence.text).toBe(200); + expect(presence.json.records.map((r: any) => r.name)).toEqual(['efb-pred']); + expect(presence.text).not.toContain(PASSWORD); + const unset = await call('POST', `/data/${ZOO}/query`, { where: { name: 'efb-pred', f_password: null } }); + expect(unset.status, unset.text).toBe(200); + expect(unset.json.records).toHaveLength(0); + + const control = await call('POST', `/data/${ZOO}/query`, { where: { name: 'efb-pred' }, orderBy: [{ field: 'name', order: 'asc' }] }); + expect(control.status, control.text).toBe(200); + expect(control.json.records).toHaveLength(1); + }); }); // ── fail-closed: no crypto provider ────────────────────────────────────── @@ -136,6 +180,7 @@ const efbStack = defineStack({ name: Field.text({ label: 'Name', required: true }), token: Field.secret({ label: 'Token' }), pin: Field.password({ label: 'PIN', ackPlaintextMasking: true }), + digest: Field.text({ label: 'Digest', internal: true }), }, }), ], @@ -178,4 +223,28 @@ describe('records-forms.encrypted-field-behavior — fail-closed with no crypto const body: any = await read.json(); expect((body.record ?? body).pin).toBe(SECRET_MASK); }); + + it('a sort or filter key on a password or an `internal: true` column is refused at the REST door', async () => { + const res = await stack.apiAs(token, 'POST', `/data/${VAULT}`, { name: 'efb-keys', pin: '7777-pin', digest: 'd-7777' }); + expect(res.status, await res.clone().text()).toBe(201); + const bodies: Array<[string, unknown]> = [ + ['orderBy pin', { orderBy: [{ field: 'pin', order: 'desc' }] }], + ['where pin', { where: { pin: { $startsWith: '7' } } }], + ['orderBy digest', { orderBy: [{ field: 'digest', order: 'desc' }] }], + ['where digest', { where: { digest: 'd-7777' } }], + ]; + for (const [label, body] of bodies) { + const answer = await stack.apiAs(token, 'POST', `/data/${VAULT}/query`, body); + const text = await answer.text(); + expect(answer.status, `${label}: ${text}`).toBe(400); + expect(JSON.parse(text).code, `${label}: ${text}`).toBe('INVALID_FIELD'); + expect(text, label).not.toContain('7777-pin'); + expect(text, label).not.toContain('d-7777'); + } + // Control: the same door sorts and filters by a plain column. + const control = await stack.apiAs(token, 'POST', `/data/${VAULT}/query`, { + where: { name: 'efb-keys' }, orderBy: [{ field: 'name', order: 'asc' }], + }); + expect(control.status, await control.clone().text()).toBe(200); + }); });