|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21544] The stored-metadata family's ONE filter-field collector and ONE |
| 5 | + * default-search narrowing — the two module functions the generic data door |
| 6 | + * calls and exports, so the in-process reader-context seam |
| 7 | + * (`@objectstack/runtime`) calls the same two (the door / seam parity table |
| 8 | + * lives beside the seam, `stored-metadata-reader-seam.test.ts`). |
| 9 | + * |
| 10 | + * The door's collector used to be the ingress key collector, and the |
| 11 | + * measurement on this card found two shapes that READ a family column at the |
| 12 | + * generic data door without the family's refusal ever seeing it, on both |
| 13 | + * `sys_metadata` and `sys_metadata_history`, over HTTP and in-process alike: |
| 14 | + * |
| 15 | + * - a cross-field comparand (`{ name: { $ne: { $field: 'metadata' } } }`), |
| 16 | + * which the SQL drivers evaluate row by row — the comparison partitioned |
| 17 | + * the rows by the stored body's value; |
| 18 | + * - a direct predicate nested below the key collector's 32-level depth |
| 19 | + * backstop, which ran as written: a body `$contains` of the stored |
| 20 | + * credential answered the row and a wrong guess answered none, on |
| 21 | + * driver-sqlite-wasm and driver-memory — a credential oracle. |
| 22 | + * |
| 23 | + * Both are refused now, before the engine is asked, in the family's envelope. |
| 24 | + */ |
| 25 | + |
| 26 | +import { describe, expect, it, vi } from 'vitest'; |
| 27 | +import { SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; |
| 28 | +import { |
| 29 | + collectStoredMetadataFilterFields, |
| 30 | + narrowStoredMetadataSearch, |
| 31 | + ObjectStackProtocolImplementation, |
| 32 | +} from './protocol.js'; |
| 33 | + |
| 34 | +/** `n` nested `$and` levels above `leaf`: the leaf sits at depth `n`. */ |
| 35 | +const deep = (n: number, leaf: Record<string, unknown>): Record<string, unknown> => |
| 36 | + (n === 0 ? leaf : { $and: [deep(n - 1, leaf)] }); |
| 37 | + |
| 38 | +const sorted = (names: readonly string[]) => [...names].sort(); |
| 39 | + |
| 40 | +describe('[#21544] collectStoredMetadataFilterFields — every column a read query\'s filters read', () => { |
| 41 | + const cases: Array<[string, unknown, string[]]> = [ |
| 42 | + ['a key', { where: { type: 'view' } }, ['type']], |
| 43 | + ['a dotted key reads its head', { where: { 'metadata.config': 'x' } }, ['metadata']], |
| 44 | + ['a cross-field comparand', { where: { name: { $eq: { $field: 'metadata' } } } }, ['metadata', 'name']], |
| 45 | + ['a reference as the implicit-equality comparand', { where: { name: { $field: 'checksum' } } }, ['checksum', 'name']], |
| 46 | + ['a reference in a list', { where: { name: { $in: [{ $field: 'checksum' }] } } }, ['checksum', 'name']], |
| 47 | + [ |
| 48 | + 'a reference in an addDays offset', |
| 49 | + { where: { created_at: { $lte: { $field: 'created_at', addDays: { $field: 'previous_checksum' } } } } }, |
| 50 | + ['created_at', 'previous_checksum'], |
| 51 | + ], |
| 52 | + ['a dotted reference reads its head', { where: { name: { $ne: { $field: 'metadata.x' } } } }, ['metadata', 'name']], |
| 53 | + [ |
| 54 | + 'under $and / $or / $not', |
| 55 | + { where: { $or: [{ $not: { name: { $lt: { $field: 'change_note' } } } }, { $and: [{ type: 'x' }] }] } }, |
| 56 | + ['change_note', 'name', 'type'], |
| 57 | + ], |
| 58 | + ['under an unrecognised $ key', { where: { $nor: [{ metadata: 'x' }] } }, ['metadata']], |
| 59 | + [ |
| 60 | + 'a nested-relation condition: its keys are another object\'s, a reference beneath it is read', |
| 61 | + { where: { organization_id: { metadata: 'x', name: { $eq: { $field: 'checksum' } } } } }, |
| 62 | + ['checksum', 'organization_id'], |
| 63 | + ], |
| 64 | + ['at any depth: a key 40 levels down', { where: deep(40, { metadata: { $contains: 'z' } }) }, ['metadata']], |
| 65 | + ['at any depth: a reference 40 levels down', { where: deep(40, { name: { $ne: { $field: 'checksum' } } }) }, ['checksum', 'name']], |
| 66 | + ['a FilterArray is lowered first', { where: [['metadata', 'contains', 'z']] }, ['metadata']], |
| 67 | + ['the engine\'s `filter` alias', { filter: { checksum: 'x' } }, ['checksum']], |
| 68 | + [ |
| 69 | + 'each aggregation filter', |
| 70 | + { aggregations: [{ function: 'count', alias: 'n', filter: { name: { $ne: { $field: 'metadata' } } } }] }, |
| 71 | + ['metadata', 'name'], |
| 72 | + ], |
| 73 | + [ |
| 74 | + 'not `having`: its names are the aggregated row\'s, an alias spelled like a family column included', |
| 75 | + { groupBy: ['type'], aggregations: [{ function: 'count', alias: 'metadata' }], having: { metadata: { $gt: 0 } } }, |
| 76 | + [], |
| 77 | + ], |
| 78 | + ]; |
| 79 | + for (const [label, query, expected] of cases) { |
| 80 | + it(label, () => { |
| 81 | + expect(sorted(collectStoredMetadataFilterFields('sys_metadata', query))).toEqual(expected); |
| 82 | + expect(sorted(collectStoredMetadataFilterFields('sys_metadata_history', query))).toEqual(expected); |
| 83 | + }); |
| 84 | + } |
| 85 | + |
| 86 | + it('[] for an object outside the family, and for a query that is not a record', () => { |
| 87 | + expect(collectStoredMetadataFilterFields('file_blob', { where: { metadata: 'x' } })).toEqual([]); |
| 88 | + expect(collectStoredMetadataFilterFields('sys_metadata', null)).toEqual([]); |
| 89 | + expect(collectStoredMetadataFilterFields('sys_metadata', [{ metadata: 'x' }])).toEqual([]); |
| 90 | + }); |
| 91 | + |
| 92 | + it('terminates on a self-referential live object, and reads a node shared as a comparand and a condition both ways', () => { |
| 93 | + const cyclic: Record<string, unknown> = { type: 'x' }; |
| 94 | + cyclic.$and = [cyclic]; |
| 95 | + expect(collectStoredMetadataFilterFields('sys_metadata', { where: cyclic })).toEqual(['type']); |
| 96 | + const shared = { metadata: 'x' }; |
| 97 | + expect(sorted(collectStoredMetadataFilterFields('sys_metadata', { where: { name: { $eq: shared }, $and: [shared] } }))) |
| 98 | + .toEqual(['metadata', 'name']); |
| 99 | + }); |
| 100 | +}); |
| 101 | + |
| 102 | +describe('[#21544] narrowStoredMetadataSearch — the one default-search narrowing', () => { |
| 103 | + function refusalOf(run: () => unknown): any { |
| 104 | + try { |
| 105 | + run(); |
| 106 | + } catch (e) { |
| 107 | + return e; |
| 108 | + } |
| 109 | + throw new Error('expected a refusal, but the search was not refused'); |
| 110 | + } |
| 111 | + |
| 112 | + it('an explicit list naming the body or a hash column is refused, under the slot the caller used', () => { |
| 113 | + for (const [query, wire, param, field] of [ |
| 114 | + [{ search: 'z', searchFields: ['name', 'metadata'] }, {}, 'searchFields', 'metadata'], |
| 115 | + [{ search: 'z', searchFields: 'name, checksum' }, {}, 'searchFields', 'checksum'], |
| 116 | + [{ search: { query: 'z', fields: ['previous_checksum'] } }, {}, 'search', 'previous_checksum'], |
| 117 | + [{ search: 'z', searchFields: 'change_note' }, { searchFields: '$searchFields' }, '$searchFields', 'change_note'], |
| 118 | + ] as const) { |
| 119 | + const err = refusalOf(() => narrowStoredMetadataSearch('sys_metadata_history', query, SysMetadataHistoryObject, wire)); |
| 120 | + expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400, param, field, object: 'sys_metadata_history' }); |
| 121 | + } |
| 122 | + }); |
| 123 | + |
| 124 | + it('an explicit list that names neither runs as it is', () => { |
| 125 | + expect(narrowStoredMetadataSearch('sys_metadata', { search: 'z', searchFields: ['name'] }, SysMetadataObject)).toBeUndefined(); |
| 126 | + }); |
| 127 | + |
| 128 | + it('a default search is narrowed to the searchable set without the body and hash columns', () => { |
| 129 | + for (const [object, schema, unscannable] of [ |
| 130 | + ['sys_metadata', SysMetadataObject, ['metadata', 'checksum']], |
| 131 | + ['sys_metadata_history', SysMetadataHistoryObject, ['metadata', 'checksum', 'previous_checksum', 'change_note']], |
| 132 | + ] as const) { |
| 133 | + const narrowed = narrowStoredMetadataSearch(object, { search: 'z' }, schema); |
| 134 | + expect(narrowed).toContain('name'); |
| 135 | + for (const column of unscannable) expect(narrowed).not.toContain(column); |
| 136 | + } |
| 137 | + }); |
| 138 | + |
| 139 | + it('a default search whose set narrows to nothing is refused, never handed on empty', () => { |
| 140 | + const onlyFamily = { ...SysMetadataObject, searchableFields: ['metadata', 'checksum'] }; |
| 141 | + const err = refusalOf(() => narrowStoredMetadataSearch('sys_metadata', { search: 'z' }, onlyFamily, { search: '$search' })); |
| 142 | + expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400, param: '$search', field: 'metadata' }); |
| 143 | + }); |
| 144 | + |
| 145 | + it('runs as it is outside the family, without a search, and without a readable field map', () => { |
| 146 | + expect(narrowStoredMetadataSearch('file_blob', { search: 'z', searchFields: ['metadata'] }, SysMetadataObject)).toBeUndefined(); |
| 147 | + expect(narrowStoredMetadataSearch('sys_metadata', { where: { type: 'view' } }, SysMetadataObject)).toBeUndefined(); |
| 148 | + for (const schema of [undefined, {}, { fields: [] }, { fields: {} }]) { |
| 149 | + expect(narrowStoredMetadataSearch('sys_metadata', { search: 'z' }, schema)).toBeUndefined(); |
| 150 | + } |
| 151 | + }); |
| 152 | +}); |
| 153 | + |
| 154 | +describe('[#21544] data door — a filter that reads the body or a hash without naming it as a key is refused', () => { |
| 155 | + const SCHEMAS: Record<string, unknown> = { |
| 156 | + sys_metadata: SysMetadataObject, |
| 157 | + sys_metadata_history: SysMetadataHistoryObject, |
| 158 | + file_blob: { name: 'file_blob', fields: { name: { name: 'name', type: 'text' }, checksum: { name: 'checksum', type: 'text' } } }, |
| 159 | + }; |
| 160 | + |
| 161 | + function makeProtocol() { |
| 162 | + const find = vi.fn(async () => []); |
| 163 | + const aggregate = vi.fn(async () => []); |
| 164 | + const count = vi.fn(async () => 0); |
| 165 | + // `findData` reads through find / count / aggregate only — no `findOne` |
| 166 | + // on this double, so a door that started calling it would fail loudly. |
| 167 | + const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, count, aggregate }; |
| 168 | + return { p: new ObjectStackProtocolImplementation(engine), find, aggregate, count }; |
| 169 | + } |
| 170 | + |
| 171 | + async function refusal(run: () => Promise<unknown>): Promise<any> { |
| 172 | + try { |
| 173 | + await run(); |
| 174 | + } catch (e) { |
| 175 | + return e; |
| 176 | + } |
| 177 | + throw new Error('expected a refusal, but the query ran'); |
| 178 | + } |
| 179 | + |
| 180 | + const shapes: Array<[string, string, (column: string) => Record<string, unknown>]> = [ |
| 181 | + ['a cross-field comparand in `where`', 'filter', (c) => ({ where: { name: { $ne: { $field: c } } } })], |
| 182 | + ['a cross-field comparand under $not', 'filter', (c) => ({ where: { $not: { type: { $lt: { $field: c } } } } })], |
| 183 | + ['a direct predicate 33 levels deep', 'filter', (c) => ({ where: deep(33, { [c]: { $contains: 'guess' } }) })], |
| 184 | + ['a cross-field comparand 33 levels deep', 'filter', (c) => ({ where: deep(33, { name: { $eq: { $field: c } } }) })], |
| 185 | + [ |
| 186 | + 'a cross-field comparand in an aggregation filter', |
| 187 | + 'filter', |
| 188 | + (c) => ({ groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n', filter: { name: { $ne: { $field: c } } } }] }), |
| 189 | + ], |
| 190 | + [ |
| 191 | + 'a direct predicate 33 levels deep in an aggregation filter', |
| 192 | + 'filter', |
| 193 | + (c) => ({ groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n', filter: deep(33, { [c]: { $contains: 'guess' } }) }] }), |
| 194 | + ], |
| 195 | + ]; |
| 196 | + const columns: Array<[string, string]> = [ |
| 197 | + ['sys_metadata', 'metadata'], |
| 198 | + ['sys_metadata', 'checksum'], |
| 199 | + ['sys_metadata_history', 'metadata'], |
| 200 | + ['sys_metadata_history', 'previous_checksum'], |
| 201 | + ['sys_metadata_history', 'change_note'], |
| 202 | + ]; |
| 203 | + for (const [label, param, build] of shapes) { |
| 204 | + for (const [object, column] of columns) { |
| 205 | + it(`${label}, reading '${column}' on '${object}': INVALID_FIELD / 400, and the engine is never asked`, async () => { |
| 206 | + const { p, find, aggregate, count } = makeProtocol(); |
| 207 | + const err = await refusal(() => p.findData({ object, query: build(column) })); |
| 208 | + expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400, param, field: column, object }); |
| 209 | + expect(find).not.toHaveBeenCalled(); |
| 210 | + expect(aggregate).not.toHaveBeenCalled(); |
| 211 | + expect(count).not.toHaveBeenCalled(); |
| 212 | + }); |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + it('control: the same shapes over scalar columns of a family table still run', async () => { |
| 217 | + const { p, find, aggregate } = makeProtocol(); |
| 218 | + await p.findData({ object: 'sys_metadata', query: { where: { name: { $ne: { $field: 'type' } } } } }); |
| 219 | + await p.findData({ object: 'sys_metadata', query: { where: deep(33, { type: 'view' }) } }); |
| 220 | + expect(find).toHaveBeenCalledTimes(2); |
| 221 | + await p.findData({ |
| 222 | + object: 'sys_metadata', |
| 223 | + query: { groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n', filter: { name: { $ne: { $field: 'type' } } } }] }, |
| 224 | + }); |
| 225 | + expect(aggregate).toHaveBeenCalledTimes(1); |
| 226 | + }); |
| 227 | + |
| 228 | + it('control: an object outside the family reads its own `checksum` through a comparand and at depth', async () => { |
| 229 | + const { p, find } = makeProtocol(); |
| 230 | + await p.findData({ object: 'file_blob', query: { where: { name: { $ne: { $field: 'checksum' } } } } }); |
| 231 | + await p.findData({ object: 'file_blob', query: { where: deep(33, { checksum: 'sha256:0000' }) } }); |
| 232 | + expect(find).toHaveBeenCalledTimes(2); |
| 233 | + }); |
| 234 | +}); |
0 commit comments