|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#23008] What the organization seed-ownership claim's write DISPATCHES — |
| 5 | + * measured on a REAL `ObjectQL` over a real `SqliteWasmDriver` (the driver |
| 6 | + * `objectstack dev` uses). The multi-tenant twin of plugin-security's |
| 7 | + * `claim-seed-ownership-dispatch.pin.test.ts`. |
| 8 | + * |
| 9 | + * `claimOrgSeedOwnership` re-owns an organization's seeded rows to its owner. |
| 10 | + * That is attribution, the step that completes the seed, not a user event, so |
| 11 | + * its write runs under `{ isSystem: true, skipAutomations: true }`. This file |
| 12 | + * pins what that context reaches on the engine, because a flag that is |
| 13 | + * silently dropped on the way would read as green in every double: |
| 14 | + * |
| 15 | + * 1. **the metadata-hook filter** — a hook bound through the real binder |
| 16 | + * (`bindHooksToEngine`, which is what stamps `entry.meta`) does not fire, |
| 17 | + * before or after, on any claimed row; |
| 18 | + * 2. **the per-row hook path** — code-registered hooks (the shape of the |
| 19 | + * audit writer, capability gates and the sharing projection) DO fire, once |
| 20 | + * per claimed row and phase, and every one of those contexts carries the |
| 21 | + * flag; |
| 22 | + * 3. **the record-change trigger's reading point** — every one of those |
| 23 | + * contexts has `session.skipTriggers === true`, the one field |
| 24 | + * `RecordChangeTrigger`'s handler reads before it dispatches a flow |
| 25 | + * (`skipAutomations` implies it, `engine.ts` `buildSession`). |
| 26 | + * |
| 27 | + * And it measures the read the flag deliberately stays off: the claim's |
| 28 | + * per-object read is a plain system read, so the same metadata hook, bound on |
| 29 | + * `beforeFind`/`afterFind` too, sees it. That is also the in-run positive |
| 30 | + * control: the metadata hook is live on this engine DURING the claim, so its |
| 31 | + * silence on the write events is the filter, not a hook that was never bound. |
| 32 | + * |
| 33 | + * Each case carries a second positive control on the SAME engine: the claim's |
| 34 | + * own write shape with a plain `{ isSystem: true }` context fires the metadata |
| 35 | + * hook per row and phase. |
| 36 | + */ |
| 37 | + |
| 38 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 39 | +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; |
| 40 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 41 | + |
| 42 | +import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; |
| 43 | + |
| 44 | +const SYS = { context: { isSystem: true } } as const; |
| 45 | +const ORG = 'org_alpha'; |
| 46 | +const OTHER_ORG = 'org_north'; |
| 47 | +const OWNER = 'usr_org_owner'; |
| 48 | +const SOMEONE = 'usr_someone'; |
| 49 | +const OBJECT = 'probe_deal'; |
| 50 | +const PACKAGE_ID = 'com.objectstack.org-claim-dispatch-23008'; |
| 51 | + |
| 52 | +const PROBE_OBJECT = { |
| 53 | + name: OBJECT, |
| 54 | + label: 'Probe Deal', |
| 55 | + fields: { |
| 56 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 57 | + name: { name: 'name', type: 'text' }, |
| 58 | + stage: { name: 'stage', type: 'text' }, |
| 59 | + owner_id: { name: 'owner_id', type: 'text' }, |
| 60 | + organization_id: { name: 'organization_id', type: 'text' }, |
| 61 | + }, |
| 62 | +}; |
| 63 | + |
| 64 | +/** One dispatch a hook saw: what the engine handed it, reduced to the flags. */ |
| 65 | +interface Seen { |
| 66 | + event: string; |
| 67 | + id: unknown; |
| 68 | + isSystem: unknown; |
| 69 | + skipAutomations: unknown; |
| 70 | + skipTriggers: unknown; |
| 71 | +} |
| 72 | + |
| 73 | +const engines: ObjectQL[] = []; |
| 74 | +afterEach(async () => { |
| 75 | + while (engines.length) { |
| 76 | + try { await engines.pop()?.destroy(); } catch { /* noop */ } |
| 77 | + } |
| 78 | +}); |
| 79 | + |
| 80 | +/** |
| 81 | + * A booted engine with ONE metadata-bound hook and ONE code-registered hook on |
| 82 | + * the probe object. |
| 83 | + * |
| 84 | + * The metadata hook is bound through `bindHooksToEngine` — the binder an app's |
| 85 | + * `defineStack({ hooks })` goes through — so its entry carries `meta` exactly as |
| 86 | + * a shipped app hook's does. It listens on the write events AND the read |
| 87 | + * events. The code hook is `registerHook` with no metadata, on the write events, |
| 88 | + * the shape of the audit writer, the sharing projection and |
| 89 | + * `RecordChangeTrigger`'s per-flow handler. |
| 90 | + */ |
| 91 | +async function boot() { |
| 92 | + const engine = new ObjectQL(); |
| 93 | + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' } as never) as never, true); |
| 94 | + await engine.init(); |
| 95 | + engine.registerApp({ |
| 96 | + id: PACKAGE_ID, |
| 97 | + name: 'Organization claim dispatch', |
| 98 | + version: '1.0.0', |
| 99 | + type: 'plugin', |
| 100 | + scope: 'system', |
| 101 | + objects: [PROBE_OBJECT], |
| 102 | + } as never); |
| 103 | + await engine.syncSchemas(); |
| 104 | + engines.push(engine); |
| 105 | + |
| 106 | + const metadataHook: Seen[] = []; |
| 107 | + const codeHook: Seen[] = []; |
| 108 | + const record = (into: Seen[]) => async (ctx: any) => { |
| 109 | + into.push({ |
| 110 | + event: ctx.event, |
| 111 | + id: ctx.input?.id, |
| 112 | + isSystem: ctx.session?.isSystem, |
| 113 | + skipAutomations: ctx.session?.skipAutomations, |
| 114 | + skipTriggers: ctx.session?.skipTriggers, |
| 115 | + }); |
| 116 | + }; |
| 117 | + |
| 118 | + const bound = bindHooksToEngine( |
| 119 | + engine, |
| 120 | + [ |
| 121 | + { |
| 122 | + name: 'probe_app_hook', |
| 123 | + object: OBJECT, |
| 124 | + events: ['beforeFind', 'afterFind', 'beforeUpdate', 'afterUpdate'], |
| 125 | + handler: record(metadataHook), |
| 126 | + } as never, |
| 127 | + ], |
| 128 | + { packageId: PACKAGE_ID }, |
| 129 | + ); |
| 130 | + expect(bound.registered, 'the metadata hook must be bound for its absence to mean anything').toBe(4); |
| 131 | + |
| 132 | + for (const event of ['beforeUpdate', 'afterUpdate'] as const) { |
| 133 | + engine.registerHook(event, record(codeHook) as never, { object: OBJECT, packageId: 'probe.code-hook' }); |
| 134 | + } |
| 135 | + |
| 136 | + return { engine, metadataHook, codeHook }; |
| 137 | +} |
| 138 | + |
| 139 | +/** Seed rows straight through the driver: the seed path is not the subject here. */ |
| 140 | +async function seed(engine: ObjectQL, rows: Record<string, unknown>[]): Promise<void> { |
| 141 | + const driver = (engine as any).getDriverForObject(OBJECT); |
| 142 | + for (let i = 0; i < rows.length; i += 500) { |
| 143 | + await driver.bulkCreate(OBJECT, rows.slice(i, i + 500)); |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +async function ownersOf(engine: ObjectQL): Promise<Map<string, unknown>> { |
| 148 | + const rows = await (engine as any).find(OBJECT, { fields: ['id', 'owner_id'] }, SYS); |
| 149 | + return new Map(rows.map((r: any) => [r.id, r.owner_id ?? null])); |
| 150 | +} |
| 151 | + |
| 152 | +const writeEvents = (seen: Seen[]) => seen.filter((s) => s.event === 'beforeUpdate' || s.event === 'afterUpdate'); |
| 153 | + |
| 154 | +describe('[#23008] the organization seed-ownership claim dispatches no metadata-bound automation (real engine)', () => { |
| 155 | + it('no metadata hook fires on the write; code hooks fire once per claimed row and phase with the flag and skipTriggers', async () => { |
| 156 | + const { engine, metadataHook, codeHook } = await boot(); |
| 157 | + const N = 12; |
| 158 | + const claimed = Array.from({ length: N }, (_, i) => `d${i}`); |
| 159 | + await seed(engine, [ |
| 160 | + ...claimed.map((id, i) => ({ id, name: `Deal ${i}`, stage: 'open', organization_id: ORG, owner_id: null })), |
| 161 | + // Out of the claim's scope: owned already, and another organization's row. |
| 162 | + { id: 'owned', name: 'Owned', stage: 'open', organization_id: ORG, owner_id: SOMEONE }, |
| 163 | + { id: 'foreign', name: 'Foreign', stage: 'open', organization_id: OTHER_ORG, owner_id: null }, |
| 164 | + ]); |
| 165 | + |
| 166 | + const result = await claimOrgSeedOwnership(engine, ORG, OWNER); |
| 167 | + // Taken before any read of the test's own: `ownersOf` is a `find` too. |
| 168 | + const metadataSeen = [...metadataHook]; |
| 169 | + const codeSeen = [...codeHook]; |
| 170 | + |
| 171 | + expect(result).toEqual([{ object: OBJECT, count: N }]); |
| 172 | + const owners = await ownersOf(engine); |
| 173 | + for (const id of claimed) expect(owners.get(id)).toBe(OWNER); |
| 174 | + expect(owners.get('owned')).toBe(SOMEONE); |
| 175 | + expect(owners.get('foreign')).toBeNull(); |
| 176 | + |
| 177 | + // ⭐ 1 — the metadata-hook filter: not one write dispatch, before or after. |
| 178 | + expect(writeEvents(metadataSeen), 'a metadata-bound hook fired for the claim write').toEqual([]); |
| 179 | + // In-run positive control, and the read's measurement: the claim's one read |
| 180 | + // of this object is a plain system read, so the same metadata hook saw it. |
| 181 | + expect(metadataSeen.map((s) => s.event)).toEqual(['beforeFind', 'afterFind']); |
| 182 | + for (const s of metadataSeen) { |
| 183 | + expect(s.isSystem).toBe(true); |
| 184 | + expect(s.skipAutomations).toBeUndefined(); |
| 185 | + } |
| 186 | + |
| 187 | + // ⭐ 2 — the per-row path still runs code-registered hooks: one dispatch per |
| 188 | + // claimed row and phase, so audit and sharing keep their per-row input. |
| 189 | + for (const event of ['beforeUpdate', 'afterUpdate']) { |
| 190 | + const ids = codeSeen.filter((s) => s.event === event).map((s) => s.id); |
| 191 | + expect([...ids].sort(), `${event} per claimed row`).toEqual([...claimed].sort()); |
| 192 | + } |
| 193 | + expect(codeSeen).toHaveLength(2 * N); |
| 194 | + // ⭐ 3 — every one of those contexts carries the flag AND `skipTriggers`, |
| 195 | + // the field the record-change trigger reads before dispatching a flow. |
| 196 | + for (const s of codeSeen) { |
| 197 | + expect(s).toEqual({ event: s.event, id: s.id, isSystem: true, skipAutomations: true, skipTriggers: true }); |
| 198 | + } |
| 199 | + |
| 200 | + // Positive control, same engine, same rows: the claim's own write shape |
| 201 | + // (a by-id `update`) under a plain system context fires the metadata hook |
| 202 | + // per row and phase, and carries no skip flag. |
| 203 | + metadataHook.length = 0; |
| 204 | + codeHook.length = 0; |
| 205 | + for (const id of claimed) { |
| 206 | + await (engine as any).update(OBJECT, { id, stage: 'touched' }, SYS); |
| 207 | + } |
| 208 | + expect(writeEvents(metadataHook)).toHaveLength(2 * N); |
| 209 | + expect(codeHook).toHaveLength(2 * N); |
| 210 | + for (const s of codeHook) { |
| 211 | + expect(s.skipAutomations).toBeUndefined(); |
| 212 | + expect(s.skipTriggers).toBeUndefined(); |
| 213 | + } |
| 214 | + }, 120_000); |
| 215 | + |
| 216 | + it('a second run claims nothing and dispatches no write hook of either kind', async () => { |
| 217 | + const { engine, metadataHook, codeHook } = await boot(); |
| 218 | + await seed(engine, [{ id: 'd0', name: 'Deal 0', stage: 'open', organization_id: ORG, owner_id: null }]); |
| 219 | + |
| 220 | + expect(await claimOrgSeedOwnership(engine, ORG, OWNER)).toEqual([{ object: OBJECT, count: 1 }]); |
| 221 | + metadataHook.length = 0; |
| 222 | + codeHook.length = 0; |
| 223 | + |
| 224 | + expect(await claimOrgSeedOwnership(engine, ORG, OWNER)).toEqual([]); |
| 225 | + expect(writeEvents(metadataHook)).toEqual([]); |
| 226 | + expect(codeHook).toEqual([]); |
| 227 | + }, 120_000); |
| 228 | +}); |
0 commit comments