|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The credential-column guard's filter / sort half (ADR-0100), against a REAL |
| 5 | + * {@link ObjectQL} engine over a minimal recording driver: every refusal is |
| 6 | + * the ADR-0112 `INVALID_FIELD` / 400 envelope and leaves the driver unasked; |
| 7 | + * a has-a-value check, a system context and every non-credential field are |
| 8 | + * served as before. |
| 9 | + */ |
| 10 | + |
| 11 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 12 | +import { ObjectQL } from './engine.js'; |
| 13 | +import { isCredentialPresenceCondition } from './credential-evaluation-guard.js'; |
| 14 | +import type { EngineAggregateOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data'; |
| 15 | + |
| 16 | +function makeRecordingDriver() { |
| 17 | + const rows = new Map<string, Record<string, unknown>[]>(); |
| 18 | + const calls: string[] = []; |
| 19 | + const matches = (row: Record<string, unknown>, where: any): boolean => { |
| 20 | + if (!where || typeof where !== 'object') return true; |
| 21 | + for (const [k, v] of Object.entries(where)) { |
| 22 | + if (k.startsWith('$')) continue; |
| 23 | + if (v !== null && typeof v === 'object') continue; |
| 24 | + if ((row[k] ?? null) !== (v ?? null)) return false; |
| 25 | + } |
| 26 | + return true; |
| 27 | + }; |
| 28 | + const driver: any = { |
| 29 | + name: 'memory', version: '0.0.0', supports: {}, |
| 30 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, |
| 31 | + async execute() { return null; }, |
| 32 | + async find(object: string, ast: any) { |
| 33 | + calls.push(`find:${object}`); |
| 34 | + const found = (rows.get(object) ?? []).filter((r) => matches(r, ast?.where)); |
| 35 | + const page = typeof ast?.limit === 'number' ? found.slice(0, ast.limit) : found; |
| 36 | + return page.map((r) => ({ ...r })); |
| 37 | + }, |
| 38 | + async findOne(object: string, ast: any) { |
| 39 | + calls.push(`findOne:${object}`); |
| 40 | + const r = (rows.get(object) ?? []).find((x) => matches(x, ast?.where)); |
| 41 | + return r ? { ...r } : null; |
| 42 | + }, |
| 43 | + async count(object: string, ast: any) { |
| 44 | + calls.push(`count:${object}`); |
| 45 | + return (rows.get(object) ?? []).filter((r) => matches(r, ast?.where)).length; |
| 46 | + }, |
| 47 | + async aggregate(object: string) { |
| 48 | + calls.push(`aggregate:${object}`); |
| 49 | + return [{ n: (rows.get(object) ?? []).length }]; |
| 50 | + }, |
| 51 | + async create(object: string, data: Record<string, unknown>) { |
| 52 | + const list = rows.get(object) ?? []; |
| 53 | + const row = { id: `r${list.length + 1}`, ...data }; |
| 54 | + list.push(row); |
| 55 | + rows.set(object, list); |
| 56 | + return { ...row }; |
| 57 | + }, |
| 58 | + async update(object: string, id: string, data: Record<string, unknown>) { |
| 59 | + calls.push(`update:${object}`); |
| 60 | + return { id, ...data }; |
| 61 | + }, |
| 62 | + async updateMany(object: string) { calls.push(`updateMany:${object}`); return 0; }, |
| 63 | + async delete(object: string) { calls.push(`delete:${object}`); return true; }, |
| 64 | + async deleteMany(object: string) { calls.push(`deleteMany:${object}`); return 0; }, |
| 65 | + async upsert(_o: string, data: Record<string, unknown>) { return data; }, |
| 66 | + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 67 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 68 | + async commit() {}, async rollback() {}, |
| 69 | + }; |
| 70 | + return { driver, rows, calls }; |
| 71 | +} |
| 72 | + |
| 73 | +const person = { |
| 74 | + name: 'cg_person', label: 'Person', |
| 75 | + fields: { |
| 76 | + id: { name: 'id', label: 'ID', type: 'text' as const }, |
| 77 | + name: { name: 'name', label: 'Name', type: 'text' as const }, |
| 78 | + pin: { name: 'pin', label: 'PIN', type: 'password' as const }, |
| 79 | + }, |
| 80 | +}; |
| 81 | + |
| 82 | +const vault = { |
| 83 | + name: 'cg_vault', label: 'Vault', |
| 84 | + searchableFields: ['name', 'pin'], |
| 85 | + fields: { |
| 86 | + id: { name: 'id', label: 'ID', type: 'text' as const }, |
| 87 | + name: { name: 'name', label: 'Name', type: 'text' as const }, |
| 88 | + pin: { name: 'pin', label: 'PIN', type: 'password' as const }, |
| 89 | + token: { name: 'token', label: 'Token', type: 'secret' as const }, |
| 90 | + holder: { name: 'holder', label: 'Holder', type: 'lookup' as const, reference: 'cg_person' }, |
| 91 | + }, |
| 92 | +}; |
| 93 | + |
| 94 | +const USER: EngineQueryOptions['context'] = { userId: 'u1' }; |
| 95 | +const SYSTEM: EngineQueryOptions['context'] = { isSystem: true }; |
| 96 | + |
| 97 | +async function buildEngine() { |
| 98 | + const engine = new ObjectQL(); |
| 99 | + const recording = makeRecordingDriver(); |
| 100 | + engine.registerDriver(recording.driver, true); |
| 101 | + await engine.init(); |
| 102 | + engine.registry.registerObject(person as any); |
| 103 | + engine.registry.registerObject(vault as any); |
| 104 | + recording.rows.set('cg_person', [{ id: 'p1', name: 'Pat', pin: '1234' }]); |
| 105 | + recording.rows.set('cg_vault', [{ id: 'v1', name: 'alpha', pin: 'p@ssw0rd!', token: 'secret:h1', holder: 'p1' }]); |
| 106 | + return { engine, ...recording }; |
| 107 | +} |
| 108 | + |
| 109 | +/** The ADR-0112 envelope the refusal carries, asserted on the thrown error. */ |
| 110 | +async function expectRefused(run: () => Promise<unknown>, field: string, param: string) { |
| 111 | + let thrown: any; |
| 112 | + try { await run(); } catch (e) { thrown = e; } |
| 113 | + expect(thrown, 'the call was refused').toBeDefined(); |
| 114 | + expect(thrown.code).toBe('INVALID_FIELD'); |
| 115 | + expect(thrown.status).toBe(400); |
| 116 | + expect(thrown.httpStatus).toBe(400); |
| 117 | + expect(thrown.field).toBe(field); |
| 118 | + expect(thrown.param).toBe(param); |
| 119 | +} |
| 120 | + |
| 121 | +describe('ADR-0100 credential-column guard — filter and sort keys', () => { |
| 122 | + let ctx: Awaited<ReturnType<typeof buildEngine>>; |
| 123 | + beforeEach(async () => { ctx = await buildEngine(); }); |
| 124 | + |
| 125 | + const refusedWheres: Array<[string, EngineQueryOptions['where'], string]> = [ |
| 126 | + ['equality', { pin: 'p@ssw0rd!' }, 'pin'], |
| 127 | + ['$eq with a value', { pin: { $eq: 'p@ssw0rd!' } }, 'pin'], |
| 128 | + ['$ne with a value', { pin: { $ne: 'x' } }, 'pin'], |
| 129 | + ['range', { pin: { $gt: 'o' } }, 'pin'], |
| 130 | + ['$startsWith', { pin: { $startsWith: 'p@' } }, 'pin'], |
| 131 | + ['$contains', { pin: { $contains: 'ssw' } }, 'pin'], |
| 132 | + ['$in', { pin: { $in: ['a', 'b'] } }, 'pin'], |
| 133 | + ['a secret column', { token: { $startsWith: 'secret:' } }, 'token'], |
| 134 | + ['inside $or', { $or: [{ name: 'alpha' }, { pin: 'x' }] }, 'pin'], |
| 135 | + ['inside $not', { $not: { pin: 'x' } }, 'pin'], |
| 136 | + ['$not over a value operator', { pin: { $not: { $eq: 'x' } } }, 'pin'], |
| 137 | + ['a cross-field comparand', { name: { $eq: { $field: 'pin' } } }, 'pin'], |
| 138 | + ['the array form', [['pin', '=', 'p@ssw0rd!']], 'pin'], |
| 139 | + ]; |
| 140 | + |
| 141 | + for (const [label, where, field] of refusedWheres) { |
| 142 | + it(`find refuses ${label} on a credential column, before the driver is asked`, async () => { |
| 143 | + await expectRefused(() => ctx.engine.find('cg_vault', { where, context: USER }), field, 'where'); |
| 144 | + expect(ctx.calls).toEqual([]); |
| 145 | + }); |
| 146 | + } |
| 147 | + |
| 148 | + it('a context-less engine call is judged like any non-system caller', async () => { |
| 149 | + await expectRefused(() => ctx.engine.find('cg_vault', { where: { pin: 'x' } }), 'pin', 'where'); |
| 150 | + }); |
| 151 | + |
| 152 | + it('refuses orderBy on a password and on a secret column', async () => { |
| 153 | + await expectRefused( |
| 154 | + () => ctx.engine.find('cg_vault', { orderBy: [{ field: 'pin', order: 'asc' }], context: USER }), 'pin', 'orderBy', |
| 155 | + ); |
| 156 | + await expectRefused( |
| 157 | + () => ctx.engine.find('cg_vault', { orderBy: [{ field: 'token', order: 'desc' }], context: USER }), 'token', 'orderBy', |
| 158 | + ); |
| 159 | + expect(ctx.calls).toEqual([]); |
| 160 | + }); |
| 161 | + |
| 162 | + it('refuses on findOne, count, aggregate (where and per-aggregation filter), update and delete', async () => { |
| 163 | + await expectRefused(() => ctx.engine.findOne('cg_vault', { where: { pin: 'x' }, context: USER }), 'pin', 'where'); |
| 164 | + await expectRefused(() => ctx.engine.count('cg_vault', { where: { pin: 'x' }, context: USER }), 'pin', 'where'); |
| 165 | + await expectRefused( |
| 166 | + () => ctx.engine.aggregate('cg_vault', { where: { pin: 'x' }, aggregations: [{ function: 'count', alias: 'n' }] }, { context: USER }), |
| 167 | + 'pin', 'where', |
| 168 | + ); |
| 169 | + await expectRefused( |
| 170 | + () => ctx.engine.aggregate('cg_vault', { |
| 171 | + aggregations: [{ function: 'count', alias: 'n', filter: { pin: { $gt: 'o' } } }], |
| 172 | + } satisfies EngineAggregateOptions, { context: USER }), |
| 173 | + 'pin', 'aggregations[0].filter', |
| 174 | + ); |
| 175 | + await expectRefused( |
| 176 | + () => ctx.engine.update('cg_vault', { name: 'z' }, { where: { pin: 'x' }, multi: true, context: USER } satisfies EngineUpdateOptions), 'pin', 'where', |
| 177 | + ); |
| 178 | + await expectRefused( |
| 179 | + () => ctx.engine.delete('cg_vault', { where: { pin: 'x' }, multi: true, context: USER } satisfies EngineDeleteOptions), 'pin', 'where', |
| 180 | + ); |
| 181 | + expect(ctx.calls).toEqual([]); |
| 182 | + }); |
| 183 | + |
| 184 | + it('refuses a credential column in an expand entry, judged against the target object', async () => { |
| 185 | + await expectRefused( |
| 186 | + () => ctx.engine.find('cg_vault', { expand: { holder: { object: 'cg_person', where: { pin: '1234' } } }, context: USER } satisfies EngineQueryOptions), |
| 187 | + 'pin', 'expand.holder.where', |
| 188 | + ); |
| 189 | + await expectRefused( |
| 190 | + () => ctx.engine.find('cg_vault', { expand: { holder: { object: 'cg_person', orderBy: [{ field: 'pin' }] } }, context: USER } satisfies EngineQueryOptions), |
| 191 | + 'pin', 'expand.holder.orderBy', |
| 192 | + ); |
| 193 | + expect(ctx.calls).toEqual([]); |
| 194 | + }); |
| 195 | + |
| 196 | + it('refuses a nested-relation condition on the related object’s credential column', async () => { |
| 197 | + await expectRefused( |
| 198 | + () => ctx.engine.find('cg_vault', { where: { holder: { pin: '1234' } }, context: USER }), 'pin', 'where', |
| 199 | + ); |
| 200 | + }); |
| 201 | + |
| 202 | + const presence: Array<[string, EngineQueryOptions['where']]> = [ |
| 203 | + ['bare null', { pin: null }], |
| 204 | + ['$null: true', { pin: { $null: true } }], |
| 205 | + ['$null: false', { pin: { $null: false } }], |
| 206 | + ['$exists', { pin: { $exists: true } }], |
| 207 | + ['$empty', { pin: { $empty: false } }], |
| 208 | + ['$eq: null', { pin: { $eq: null } }], |
| 209 | + ['$ne: null', { token: { $ne: null } }], |
| 210 | + ['$not over a presence check', { pin: { $not: { $null: true } } }], |
| 211 | + ]; |
| 212 | + for (const [label, where] of presence) { |
| 213 | + it(`serves a has-a-value check (${label})`, async () => { |
| 214 | + await expect(ctx.engine.find('cg_vault', { where, context: USER })).resolves.toBeInstanceOf(Array); |
| 215 | + expect(ctx.calls).toContain('find:cg_vault'); |
| 216 | + }); |
| 217 | + } |
| 218 | + |
| 219 | + it('serves a system context unchanged — the server’s own match on a credential column', async () => { |
| 220 | + const rows = await ctx.engine.find('cg_vault', { where: { pin: 'p@ssw0rd!' }, context: SYSTEM }); |
| 221 | + expect(rows.map((r: any) => r.id)).toEqual(['v1']); |
| 222 | + await expect(ctx.engine.find('cg_vault', { orderBy: [{ field: 'pin', order: 'asc' }], context: SYSTEM })).resolves.toHaveLength(1); |
| 223 | + await expect(ctx.engine.count('cg_vault', { where: { pin: 'p@ssw0rd!' }, context: SYSTEM })).resolves.toBe(1); |
| 224 | + }); |
| 225 | + |
| 226 | + it('leaves every non-credential field unaffected', async () => { |
| 227 | + const rows = await ctx.engine.find('cg_vault', { |
| 228 | + where: { name: 'alpha', holder: 'p1' }, orderBy: [{ field: 'name', order: 'asc' }], context: USER, |
| 229 | + }); |
| 230 | + expect(rows.map((r: any) => r.id)).toEqual(['v1']); |
| 231 | + await expect(ctx.engine.count('cg_vault', { where: { name: 'alpha' }, context: USER })).resolves.toBe(1); |
| 232 | + }); |
| 233 | + |
| 234 | + it('a search never scans a credential column, even one the author listed as searchable', async () => { |
| 235 | + let seenWhere: unknown; |
| 236 | + const original = ctx.driver.find; |
| 237 | + ctx.driver.find = async (object: string, ast: any) => { seenWhere = ast?.where; return original.call(ctx.driver, object, ast); }; |
| 238 | + await ctx.engine.find('cg_vault', { search: 'p@ss', context: USER }); |
| 239 | + expect(JSON.stringify(seenWhere)).toContain('"name"'); |
| 240 | + expect(JSON.stringify(seenWhere)).not.toContain('"pin"'); |
| 241 | + }); |
| 242 | +}); |
| 243 | + |
| 244 | +describe('isCredentialPresenceCondition', () => { |
| 245 | + it('accepts only presence shapes', () => { |
| 246 | + expect(isCredentialPresenceCondition(null)).toBe(true); |
| 247 | + expect(isCredentialPresenceCondition({ $null: true })).toBe(true); |
| 248 | + expect(isCredentialPresenceCondition({ $ne: null })).toBe(true); |
| 249 | + expect(isCredentialPresenceCondition({})).toBe(false); |
| 250 | + expect(isCredentialPresenceCondition('x')).toBe(false); |
| 251 | + expect(isCredentialPresenceCondition({ $null: 'yes' })).toBe(false); |
| 252 | + expect(isCredentialPresenceCondition({ $null: true, $eq: 'x' })).toBe(false); |
| 253 | + expect(isCredentialPresenceCondition({ $exists: true, $empty: false })).toBe(true); |
| 254 | + }); |
| 255 | +}); |
0 commit comments