|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21109, ruling A] The row-level write `check` judges a `date`, `datetime` or |
| 5 | + * `time` column in its STORED form, so the write and the read the same policy |
| 6 | + * scopes give one answer for one row. |
| 7 | + * |
| 8 | + * Driven through the real engine, the real plugin and two SQL driver families, |
| 9 | + * as a member who resolves a permission set. Each cell writes one value under a |
| 10 | + * policy whose `using` and `check` are the same predicate, then asks the read: |
| 11 | + * the write is admitted exactly when the read shows the row that value is |
| 12 | + * stored as. Measured on `main` before the step (`rls-check-stored-form.ts`): |
| 13 | + * |
| 14 | + * | `check` | written | write, before | stored | read | |
| 15 | + * |---|---|---|---|---| |
| 16 | + * | `record.due_on == '2026-01-05'` | `'2026-01-05T15:00:00Z'`, a `Date` | 403 | `2026-01-05` | shown | |
| 17 | + * | `record.due_on > '2026-01-05'` | `'2026-01-05T15:00:00Z'` | admitted | `2026-01-05` | hidden | |
| 18 | + * | `record.start_time == '09:00'` | `'09:00:00'` | 403 | `09:00:00` | shown | |
| 19 | + * | `record.due_at == '2026-01-05T10:00:00Z'` | `'2026-01-05T18:00:00+08:00'` | 403 | `2026-01-05T10:00:00.000Z` | shown | |
| 20 | + * |
| 21 | + * ## formula's whole-day copy is out of reach here |
| 22 | + * |
| 23 | + * `@objectstack/formula`'s matcher carries its own copy of the whole-day upper |
| 24 | + * bound (a bare-day `$lte` read as "through that day"), which today is what |
| 25 | + * admits a `due_on` written as an instant or a `Date` on the boundary day under |
| 26 | + * `record.due_on <= '2026-01-05'`. Ruling A retires that copy once the check |
| 27 | + * judges the stored form, so these cells must pass WITHOUT it: the matcher is |
| 28 | + * wrapped so a `$lte` reaches it as `$lt` or `$eq`, and a `$between` as `$gte` |
| 29 | + * plus that, and the copy never runs. The first cell pins that the wrapper is |
| 30 | + * live. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 34 | +// The mocked module (see `vi.mock` below), loaded at module top. |
| 35 | +import { matchesFilterCondition } from '@objectstack/formula'; |
| 36 | +import { ObjectQL } from '@objectstack/objectql'; |
| 37 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 38 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 39 | +import { PermissionSetSchema } from '@objectstack/spec/security'; |
| 40 | +import { SecurityPlugin } from './security-plugin.js'; |
| 41 | +import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
| 42 | +import { declaredTemporalColumns, storedFormCheckFilter, storedFormImage } from './rls-check-stored-form.js'; |
| 43 | + |
| 44 | +/** A plain object — a filter node or an operator map. */ |
| 45 | +function isPlain(value: unknown): value is Record<string, unknown> { |
| 46 | + return value !== null && typeof value === 'object' && !Array.isArray(value) |
| 47 | + && Object.getPrototypeOf(value) === Object.prototype; |
| 48 | +} |
| 49 | + |
| 50 | +/** `$lte` as `$lt` or `$eq`, so the matcher's whole-day copy is never reached. */ |
| 51 | +const plainLte = (column: string, bound: unknown) => ({ |
| 52 | + $or: [{ [column]: { $lt: bound } }, { [column]: { $eq: bound } }], |
| 53 | +}); |
| 54 | + |
| 55 | +/** The filter with every `$lte` and `$between` spelled without the operators the copy serves. */ |
| 56 | +function withoutWholeDayCopy(node: unknown): unknown { |
| 57 | + if (!isPlain(node)) return node; |
| 58 | + const conjuncts: unknown[] = []; |
| 59 | + for (const [key, value] of Object.entries(node)) { |
| 60 | + if ((key === '$and' || key === '$or') && Array.isArray(value)) { |
| 61 | + conjuncts.push({ [key]: value.map(withoutWholeDayCopy) }); |
| 62 | + } else if (key === '$not') { |
| 63 | + conjuncts.push({ $not: withoutWholeDayCopy(value) }); |
| 64 | + } else if (key.startsWith('$') || !isPlain(value) || !('$lte' in value || '$between' in value)) { |
| 65 | + conjuncts.push({ [key]: value }); |
| 66 | + } else { |
| 67 | + const { $lte, $between, ...rest } = value; |
| 68 | + if (Object.keys(rest).length > 0) conjuncts.push({ [key]: rest }); |
| 69 | + if ($lte !== undefined) conjuncts.push(plainLte(key, $lte)); |
| 70 | + if (Array.isArray($between)) conjuncts.push({ [key]: { $gte: $between[0] } }, plainLte(key, $between[1])); |
| 71 | + } |
| 72 | + } |
| 73 | + return conjuncts.length === 1 ? conjuncts[0] : { $and: conjuncts }; |
| 74 | +} |
| 75 | + |
| 76 | +vi.mock('@objectstack/formula', async (importOriginal) => { |
| 77 | + const real = await importOriginal<typeof import('@objectstack/formula')>(); |
| 78 | + return { |
| 79 | + ...real, |
| 80 | + matchesFilterCondition: ((record, filter, options) => |
| 81 | + real.matchesFilterCondition(record, withoutWholeDayCopy(filter) as never, options)) as typeof real.matchesFilterCondition, |
| 82 | + }; |
| 83 | +}); |
| 84 | + |
| 85 | +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; |
| 86 | +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; |
| 87 | + |
| 88 | +type Driver = { disconnect?: () => Promise<void> }; |
| 89 | +const DRIVERS: Array<[name: string, make: () => Driver]> = [ |
| 90 | + ['driver-sql (better-sqlite3)', () => |
| 91 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true })], |
| 92 | + ['driver-sqlite-wasm', () => new SqliteWasmDriver({ filename: ':memory:' })], |
| 93 | +]; |
| 94 | + |
| 95 | +const booted: ObjectQL[] = []; |
| 96 | +afterEach(async () => { |
| 97 | + while (booted.length) { |
| 98 | + try { await booted.pop()!.destroy(); } catch { /* noop */ } |
| 99 | + } |
| 100 | +}); |
| 101 | + |
| 102 | +let seq = 0; |
| 103 | +/** One engine and plugin, with ONE policy whose `using` and `check` are the same predicate. */ |
| 104 | +async function boot(makeDriver: () => Driver, predicate: string) { |
| 105 | + const OBJ = `qa_due_stored_${process.pid}_${++seq}`; |
| 106 | + const engine = new ObjectQL(); |
| 107 | + engine.registerDriver(makeDriver() as never, true); |
| 108 | + await engine.init(); |
| 109 | + engine.registerApp({ |
| 110 | + id: `com.objectstack.qa.rls-check-stored-form-${seq}`, |
| 111 | + name: 'RLS check stored form', |
| 112 | + version: '1.0.0', |
| 113 | + type: 'plugin', |
| 114 | + scope: 'system', |
| 115 | + objects: [ |
| 116 | + { |
| 117 | + name: OBJ, |
| 118 | + label: 'Duty', |
| 119 | + sharingModel: 'public_read_write', |
| 120 | + fields: { |
| 121 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 122 | + title: { name: 'title', type: 'text' }, |
| 123 | + due_on: { name: 'due_on', type: 'date' }, |
| 124 | + due_at: { name: 'due_at', type: 'datetime' }, |
| 125 | + start_time: { name: 'start_time', type: 'time' }, |
| 126 | + }, |
| 127 | + }, |
| 128 | + ], |
| 129 | + } as never); |
| 130 | + await engine.syncSchemas(); |
| 131 | + booted.push(engine); |
| 132 | + |
| 133 | + const set = PermissionSetSchema.parse({ |
| 134 | + name: 'qa_due_guard', |
| 135 | + objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, |
| 136 | + rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using: predicate, check: predicate }], |
| 137 | + }); |
| 138 | + const services: Record<string, unknown> = { |
| 139 | + manifest: { register: vi.fn() }, |
| 140 | + objectql: engine, |
| 141 | + metadata: { |
| 142 | + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, |
| 143 | + list: async () => [MEMBER_DEFAULT, set], |
| 144 | + }, |
| 145 | + }; |
| 146 | + const ctx = { |
| 147 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 148 | + registerService: vi.fn(), |
| 149 | + getService: (name: string) => { |
| 150 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 151 | + return services[name]; |
| 152 | + }, |
| 153 | + }; |
| 154 | + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); |
| 155 | + await plugin.init(ctx as never); |
| 156 | + await plugin.start(ctx as never); |
| 157 | + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); |
| 158 | + |
| 159 | + const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' }; |
| 160 | + const storedRow = async (id: string) => |
| 161 | + ((await engine.find(OBJ, { where: { id }, context: SYS_CTX } as never)) as Array<Record<string, unknown>>)[0]; |
| 162 | + const shownTo = async (id: string) => |
| 163 | + ((await engine.find(OBJ, { where: { id }, context: caller } as never)) as unknown[]).length > 0; |
| 164 | + return { OBJ, engine, caller, storedRow, shownTo }; |
| 165 | +} |
| 166 | + |
| 167 | +type Envelope = { code: string; status: number }; |
| 168 | +const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 }; |
| 169 | +const envelopeOf = (e: unknown): Envelope => { |
| 170 | + const x = e as { code?: string; status?: number; statusCode?: number }; |
| 171 | + return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) }; |
| 172 | +}; |
| 173 | +const outcome = (p: Promise<unknown>): Promise<'admitted' | Envelope> => |
| 174 | + p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e)); |
| 175 | + |
| 176 | +const show = (v: unknown): string => (v instanceof Date ? `Date(${v.toISOString()})` : JSON.stringify(v)); |
| 177 | + |
| 178 | +type Cell = { predicate: string; column: string; value: unknown; stored: unknown; admitted: boolean }; |
| 179 | +const CELLS: Cell[] = [ |
| 180 | + // `date`, the boundary day under a bare-day `$lte`: the three ways an SDK writes it. |
| 181 | + { predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: true }, |
| 182 | + { predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: new Date('2026-01-05T15:00:00Z'), stored: '2026-01-05', admitted: true }, |
| 183 | + { predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-05', stored: '2026-01-05', admitted: true }, |
| 184 | + { predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-06T01:00:00Z', stored: '2026-01-06', admitted: false }, |
| 185 | + // `date` equality: refused before for an instant or a `Date` on the day. |
| 186 | + { predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: true }, |
| 187 | + { predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: new Date('2026-01-05T15:00:00Z'), stored: '2026-01-05', admitted: true }, |
| 188 | + { predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: '2026-01-06', stored: '2026-01-06', admitted: false }, |
| 189 | + // `date`, strictly after the day: ADMITTED before, while the read hid the stored day. |
| 190 | + { predicate: "record.due_on > '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: false }, |
| 191 | + // `datetime`: one instant in two zones, and a comparand the policy spells unlike the stored form. |
| 192 | + { predicate: "record.due_at == '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T18:00:00+08:00', stored: '2026-01-05T10:00:00.000Z', admitted: true }, |
| 193 | + { predicate: "record.due_at == '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T10:00:01Z', stored: '2026-01-05T10:00:01.000Z', admitted: false }, |
| 194 | + { predicate: "record.due_at >= '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T10:00:00Z', stored: '2026-01-05T10:00:00.000Z', admitted: true }, |
| 195 | + // `time`: a wall clock written with and without seconds. |
| 196 | + { predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:00:00', stored: '09:00:00', admitted: true }, |
| 197 | + { predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:00', stored: '09:00:00', admitted: true }, |
| 198 | + { predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:01', stored: '09:01:00', admitted: false }, |
| 199 | + // Control: a TEXT column is judged as written, whatever its value looks like. |
| 200 | + { predicate: "record.title == '2026-01-05'", column: 'title', value: '2026-01-05T15:00:00Z', stored: '2026-01-05T15:00:00Z', admitted: false }, |
| 201 | + { predicate: "record.title > '2026-01-05'", column: 'title', value: '2026-01-05T15:00:00Z', stored: '2026-01-05T15:00:00Z', admitted: true }, |
| 202 | +]; |
| 203 | + |
| 204 | +describe("formula's whole-day copy is out of reach in this file", () => { |
| 205 | + it('a bare-day $lte no longer admits an instant later on that day', () => { |
| 206 | + expect(matchesFilterCondition({ d: '2026-01-05T15:00:00Z' }, { d: { $lte: '2026-01-05' } } as never)).toBe(false); |
| 207 | + expect(matchesFilterCondition({ d: '2026-01-05' }, { d: { $lte: '2026-01-05' } } as never)).toBe(true); |
| 208 | + }); |
| 209 | +}); |
| 210 | + |
| 211 | +for (const [driverName, makeDriver] of DRIVERS) { |
| 212 | + describe(`${driverName}: the write check and the read give one answer for one temporal row`, () => { |
| 213 | + for (const cell of CELLS) { |
| 214 | + const verdict = cell.admitted ? 'admitted and shown' : 'refused 403 and hidden'; |
| 215 | + it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => { |
| 216 | + const r = await boot(makeDriver, cell.predicate); |
| 217 | + const written = await outcome(r.engine.insert(r.OBJ, { id: 'w', [cell.column]: cell.value }, { context: r.caller } as never)); |
| 218 | + expect(written).toEqual(cell.admitted ? 'admitted' : DENIED); |
| 219 | + // The read under the same predicate, over the same value stored by the system. |
| 220 | + await r.engine.insert(r.OBJ, { id: 'r', [cell.column]: cell.value }, { context: SYS_CTX } as never); |
| 221 | + expect((await r.storedRow('r'))?.[cell.column]).toEqual(cell.stored); |
| 222 | + expect(await r.shownTo('r')).toBe(cell.admitted); |
| 223 | + // A refused write stored nothing; an admitted one stored the same row. |
| 224 | + const w = await r.storedRow('w'); |
| 225 | + expect(w === undefined ? undefined : w[cell.column]).toEqual(cell.admitted ? cell.stored : undefined); |
| 226 | + }); |
| 227 | + } |
| 228 | + |
| 229 | + it("a by-id update judges the stored form too: onto the day as an instant 'admitted', onto the next day 403 and unchanged", async () => { |
| 230 | + const r = await boot(makeDriver, "record.due_on == '2026-01-05'"); |
| 231 | + await r.engine.insert(r.OBJ, { id: 'u', due_on: '2026-01-05' }, { context: SYS_CTX } as never); |
| 232 | + expect(await outcome(r.engine.update(r.OBJ, { due_on: '2026-01-05T23:30:00Z' }, { where: { id: 'u' }, context: r.caller } as never))) |
| 233 | + .toBe('admitted'); |
| 234 | + expect(await outcome(r.engine.update(r.OBJ, { due_on: new Date('2026-01-06T00:30:00Z') }, { where: { id: 'u' }, context: r.caller } as never))) |
| 235 | + .toEqual(DENIED); |
| 236 | + expect((await r.storedRow('u'))?.due_on).toBe('2026-01-05'); |
| 237 | + }); |
| 238 | + }); |
| 239 | +} |
| 240 | + |
| 241 | +describe('the stored-form step reads the declaration, never the values', () => { |
| 242 | + const COLUMNS = declaredTemporalColumns({ |
| 243 | + fields: { |
| 244 | + due_on: { type: 'date', multiple: false }, |
| 245 | + due_at: { type: 'datetime', multiple: false }, |
| 246 | + start_time: { type: 'time', multiple: false }, |
| 247 | + title: { type: 'text', multiple: false }, |
| 248 | + }, |
| 249 | + }); |
| 250 | + |
| 251 | + it('names exactly the declared date / datetime / time columns, and none without a declaration', () => { |
| 252 | + expect([...COLUMNS.entries()]).toEqual([['due_on', 'date'], ['due_at', 'datetime'], ['start_time', 'time']]); |
| 253 | + expect(declaredTemporalColumns(undefined).size).toBe(0); |
| 254 | + }); |
| 255 | + |
| 256 | + it('puts every value comparand on a temporal column into its form, and leaves everything else as written', () => { |
| 257 | + const filter = { |
| 258 | + $and: [ |
| 259 | + { due_on: '2026-01-05T15:00:00Z' }, |
| 260 | + { due_on: { $in: ['2026-01-05T15:00:00Z', '2026-01-06'], $null: false } }, |
| 261 | + { start_time: { $between: ['09:00', '17:30'] } }, |
| 262 | + { due_at: { $gte: '2026-01-05T18:00:00+08:00', $contains: '2026' } }, |
| 263 | + { due_on: { $lte: { $field: 'title' } } }, |
| 264 | + { title: '2026-01-05T15:00:00Z' }, |
| 265 | + ], |
| 266 | + }; |
| 267 | + const before = JSON.stringify(filter); |
| 268 | + expect(storedFormCheckFilter(filter, COLUMNS)).toEqual({ |
| 269 | + $and: [ |
| 270 | + { due_on: '2026-01-05' }, |
| 271 | + { due_on: { $in: ['2026-01-05', '2026-01-06'], $null: false } }, |
| 272 | + { start_time: { $between: ['09:00:00', '17:30:00'] } }, |
| 273 | + { due_at: { $gte: '2026-01-05T10:00:00.000Z', $contains: '2026' } }, |
| 274 | + { due_on: { $lte: { $field: 'title' } } }, |
| 275 | + { title: '2026-01-05T15:00:00Z' }, |
| 276 | + ], |
| 277 | + }); |
| 278 | + expect(JSON.stringify(filter)).toBe(before); |
| 279 | + }); |
| 280 | + |
| 281 | + it('hands back the same filter and image when nothing is temporal (copy-on-write)', () => { |
| 282 | + const filter = { $or: [{ title: '2026-01-05T15:00:00Z' }, { due_on: { $null: true } }] }; |
| 283 | + expect(storedFormCheckFilter(filter, COLUMNS)).toBe(filter); |
| 284 | + const image = { title: '2026-01-05T15:00:00Z', due_on: '2026-01-05' }; |
| 285 | + expect(storedFormImage(image, COLUMNS)).toBe(image); |
| 286 | + expect(storedFormImage({ due_on: new Date('2026-01-05T15:00:00Z') }, COLUMNS)).toEqual({ due_on: '2026-01-05' }); |
| 287 | + }); |
| 288 | +}); |
0 commit comments