|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * bootstrapDeclaredWebhooks — the ingestion bridge that closes #3461. |
| 5 | + * |
| 6 | + * Verifies that stack/connector-declared `webhook` metadata (spec shape: |
| 7 | + * `object` / `isActive`) is materialized into `sys_webhook` data rows |
| 8 | + * (`object_name` / `active` / `definition_json`), idempotently and without |
| 9 | + * clobbering admin edits — and that the dispatcher then sees those rows. |
| 10 | + */ |
| 11 | + |
| 12 | +import { describe, expect, it, vi } from 'vitest'; |
| 13 | +import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js'; |
| 14 | +import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; |
| 15 | +import { bindWebhookProvenanceStamp } from './webhook-provenance.js'; |
| 16 | + |
| 17 | +// --------------------------------------------------------------------------- |
| 18 | +// Fakes |
| 19 | +// --------------------------------------------------------------------------- |
| 20 | + |
| 21 | +interface HookEntry { |
| 22 | + event: string; |
| 23 | + handler: (ctx: any) => any; |
| 24 | + object?: string; |
| 25 | + packageId?: string; |
| 26 | +} |
| 27 | + |
| 28 | +/** |
| 29 | + * A small engine fake that mirrors the real ObjectQL surface the bridge and |
| 30 | + * provenance hook touch: `find({ filter | where })`, `insert`, update-by-id (the |
| 31 | + * patch carries `id`, no `where`), a `_registry.listItems(type)` for declared |
| 32 | + * metadata, and `beforeUpdate` hooks that run inside `update()`. |
| 33 | + */ |
| 34 | +class FakeEngine { |
| 35 | + rows: Record<string, any[]> = {}; |
| 36 | + private hooks: HookEntry[] = []; |
| 37 | + private declared: Record<string, any[]> = {}; |
| 38 | + |
| 39 | + constructor(seed?: { rows?: Record<string, any[]>; declared?: Record<string, any[]> }) { |
| 40 | + if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows)); |
| 41 | + if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared)); |
| 42 | + } |
| 43 | + |
| 44 | + // Declared-metadata registry (where manifest decomposition parks stack.webhooks). |
| 45 | + get _registry() { |
| 46 | + return { |
| 47 | + listItems: (type: string) => (this.declared[type] ?? []).map((content) => ({ content })), |
| 48 | + }; |
| 49 | + } |
| 50 | + |
| 51 | + private matches(row: any, cond?: Record<string, any>): boolean { |
| 52 | + if (!cond) return true; |
| 53 | + return Object.entries(cond).every(([k, v]) => row[k] === v); |
| 54 | + } |
| 55 | + |
| 56 | + async find(name: string, q?: any): Promise<any[]> { |
| 57 | + const all = this.rows[name] ?? []; |
| 58 | + const cond = q?.filter ?? q?.where; |
| 59 | + const out = all.filter((r) => this.matches(r, cond)); |
| 60 | + return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; |
| 61 | + } |
| 62 | + async findOne(name: string, q?: any): Promise<any> { |
| 63 | + return (await this.find(name, q))[0] ?? null; |
| 64 | + } |
| 65 | + async insert(name: string, data: any): Promise<any> { |
| 66 | + const arr = (this.rows[name] = this.rows[name] ?? []); |
| 67 | + arr.push({ ...data }); |
| 68 | + return data; |
| 69 | + } |
| 70 | + async update(name: string, data: any, opts?: any): Promise<any> { |
| 71 | + // Run beforeUpdate hooks (the provenance stamp lives here). |
| 72 | + const id = data?.id ?? opts?.where?.id; |
| 73 | + const ctx = { input: { id, data }, session: opts?.context }; |
| 74 | + for (const h of this.hooks) { |
| 75 | + if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) { |
| 76 | + await h.handler(ctx); |
| 77 | + } |
| 78 | + } |
| 79 | + const arr = this.rows[name] ?? []; |
| 80 | + const cond = opts?.where ?? (id ? { id } : undefined); |
| 81 | + for (const r of arr) { |
| 82 | + if (this.matches(r, cond)) Object.assign(r, data); |
| 83 | + } |
| 84 | + return { affected: 0 }; |
| 85 | + } |
| 86 | + async delete(): Promise<any> { |
| 87 | + return { affected: 0 }; |
| 88 | + } |
| 89 | + async count(name: string): Promise<number> { |
| 90 | + return (this.rows[name] ?? []).length; |
| 91 | + } |
| 92 | + async aggregate(): Promise<any[]> { |
| 93 | + return []; |
| 94 | + } |
| 95 | + |
| 96 | + registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void { |
| 97 | + this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); |
| 98 | + } |
| 99 | + unregisterHooksByPackage(packageId: string): number { |
| 100 | + const before = this.hooks.length; |
| 101 | + this.hooks = this.hooks.filter((h) => h.packageId !== packageId); |
| 102 | + return before - this.hooks.length; |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +class FakeRealtime { |
| 107 | + private subs = new Map<string, { handler: any; opts?: any }>(); |
| 108 | + private n = 0; |
| 109 | + async publish(event: any): Promise<void> { |
| 110 | + for (const sub of this.subs.values()) { |
| 111 | + const o = sub.opts ?? {}; |
| 112 | + if (o.object && event.object !== o.object) continue; |
| 113 | + await sub.handler(event); |
| 114 | + } |
| 115 | + } |
| 116 | + async subscribe(_channel: string, handler: any, opts?: any): Promise<string> { |
| 117 | + const id = `s-${++this.n}`; |
| 118 | + this.subs.set(id, { handler, opts }); |
| 119 | + return id; |
| 120 | + } |
| 121 | + async unsubscribe(id: string): Promise<void> { |
| 122 | + this.subs.delete(id); |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] }; |
| 127 | + |
| 128 | +function declaredWebhook(over: Record<string, any> = {}): any { |
| 129 | + return { |
| 130 | + name: 'task_changed', |
| 131 | + label: 'Task Changed', |
| 132 | + object: 'showcase_task', |
| 133 | + triggers: ['create', 'update'], |
| 134 | + url: 'https://hooks.example/task', |
| 135 | + method: 'POST', |
| 136 | + isActive: true, |
| 137 | + ...over, |
| 138 | + }; |
| 139 | +} |
| 140 | + |
| 141 | +async function flush() { |
| 142 | + await new Promise((r) => setTimeout(r, 0)); |
| 143 | +} |
| 144 | + |
| 145 | +// --------------------------------------------------------------------------- |
| 146 | +// Tests |
| 147 | +// --------------------------------------------------------------------------- |
| 148 | + |
| 149 | +describe('bootstrapDeclaredWebhooks', () => { |
| 150 | + it('materializes a declared webhook into a sys_webhook row (object→object_name, isActive→active)', async () => { |
| 151 | + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); |
| 152 | + const res = await bootstrapDeclaredWebhooks(engine as any, null); |
| 153 | + |
| 154 | + expect(res).toEqual({ seeded: 1, skipped: 0 }); |
| 155 | + const rows = engine.rows['sys_webhook']; |
| 156 | + expect(rows).toHaveLength(1); |
| 157 | + const row = rows[0]; |
| 158 | + expect(row.name).toBe('task_changed'); |
| 159 | + expect(row.object_name).toBe('showcase_task'); // object → object_name |
| 160 | + expect(row.active).toBe(true); // isActive → active |
| 161 | + expect(row.method).toBe('post'); // lowercased to match the select options |
| 162 | + expect(row.managed_by).toBe('package'); |
| 163 | + expect(row.customized).toBe(false); |
| 164 | + // Full validated envelope stashed for the enqueuer's advanced-config read. |
| 165 | + const defn = JSON.parse(row.definition_json); |
| 166 | + expect(defn.object).toBe('showcase_task'); |
| 167 | + expect(defn.timeoutMs).toBe(30000); // default filled by WebhookSchema.parse |
| 168 | + }); |
| 169 | + |
| 170 | + it('maps isActive:false → active:false so a placeholder webhook ships inactive', async () => { |
| 171 | + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] } }); |
| 172 | + await bootstrapDeclaredWebhooks(engine as any, null); |
| 173 | + expect(engine.rows['sys_webhook'][0].active).toBe(false); |
| 174 | + }); |
| 175 | + |
| 176 | + it('is idempotent — a second boot updates in place, never duplicates', async () => { |
| 177 | + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); |
| 178 | + await bootstrapDeclaredWebhooks(engine as any, null); |
| 179 | + await bootstrapDeclaredWebhooks(engine as any, null); |
| 180 | + expect(engine.rows['sys_webhook']).toHaveLength(1); |
| 181 | + }); |
| 182 | + |
| 183 | + it('propagates a declared change to a pristine (non-customized) row', async () => { |
| 184 | + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); |
| 185 | + await bootstrapDeclaredWebhooks(engine as any, null); |
| 186 | + |
| 187 | + engine['declared'].webhook = [declaredWebhook({ url: 'https://hooks.example/task-v2' })]; |
| 188 | + await bootstrapDeclaredWebhooks(engine as any, null); |
| 189 | + |
| 190 | + expect(engine.rows['sys_webhook']).toHaveLength(1); |
| 191 | + expect(engine.rows['sys_webhook'][0].url).toBe('https://hooks.example/task-v2'); |
| 192 | + }); |
| 193 | + |
| 194 | + it('seed-not-clobber: an admin edit (customized) survives the next boot', async () => { |
| 195 | + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); |
| 196 | + bindWebhookProvenanceStamp(engine as any); |
| 197 | + await bootstrapDeclaredWebhooks(engine as any, null); |
| 198 | + |
| 199 | + // Admin deactivates the noisy webhook through the CRUD door (non-system). |
| 200 | + const id = engine.rows['sys_webhook'][0].id; |
| 201 | + await engine.update('sys_webhook', { id, active: false }, { context: ADMIN_CTX }); |
| 202 | + expect(engine.rows['sys_webhook'][0].customized).toBe(true); // hook stamped it |
| 203 | + |
| 204 | + // Redeploy re-runs the seeder — the declared row is still active:true, but |
| 205 | + // the admin's active:false must win. |
| 206 | + await bootstrapDeclaredWebhooks(engine as any, null); |
| 207 | + expect(engine.rows['sys_webhook'][0].active).toBe(false); |
| 208 | + }); |
| 209 | + |
| 210 | + it('never overwrites an admin-authored row that collides by name', async () => { |
| 211 | + const engine = new FakeEngine({ |
| 212 | + rows: { |
| 213 | + sys_webhook: [ |
| 214 | + { id: 'admin-1', name: 'task_changed', url: 'https://admin.example', active: true, managed_by: 'admin', customized: false }, |
| 215 | + ], |
| 216 | + }, |
| 217 | + declared: { webhook: [declaredWebhook()] }, |
| 218 | + }); |
| 219 | + const res = await bootstrapDeclaredWebhooks(engine as any, null); |
| 220 | + expect(res).toEqual({ seeded: 0, skipped: 1 }); |
| 221 | + expect(engine.rows['sys_webhook']).toHaveLength(1); |
| 222 | + expect(engine.rows['sys_webhook'][0].url).toBe('https://admin.example'); // untouched |
| 223 | + }); |
| 224 | + |
| 225 | + it('skips an invalid declared webhook (bad URL) with a warning, without crashing boot', async () => { |
| 226 | + const warn = vi.fn(); |
| 227 | + const engine = new FakeEngine({ |
| 228 | + declared: { webhook: [declaredWebhook({ name: 'good' }), declaredWebhook({ name: 'bad', url: 'not-a-url' })] }, |
| 229 | + }); |
| 230 | + const res = await bootstrapDeclaredWebhooks(engine as any, null, { warn }); |
| 231 | + |
| 232 | + expect(res.seeded).toBe(1); // the good one still lands |
| 233 | + expect(res.skipped).toBe(1); |
| 234 | + expect(engine.rows['sys_webhook'].map((r) => r.name)).toEqual(['good']); |
| 235 | + expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed validation'), expect.objectContaining({ name: 'bad' })); |
| 236 | + }); |
| 237 | + |
| 238 | + it('is a no-op when nothing is declared', async () => { |
| 239 | + const engine = new FakeEngine(); |
| 240 | + const res = await bootstrapDeclaredWebhooks(engine as any, null); |
| 241 | + expect(res).toEqual({ seeded: 0, skipped: 0 }); |
| 242 | + expect(engine.rows['sys_webhook']).toBeUndefined(); |
| 243 | + }); |
| 244 | + |
| 245 | + it('end-to-end: a declared webhook, once materialized, dispatches on a matching data event', async () => { |
| 246 | + const engine = new FakeEngine({ |
| 247 | + declared: { |
| 248 | + webhook: [ |
| 249 | + declaredWebhook({ |
| 250 | + triggers: ['create'], |
| 251 | + secret: 'shh', |
| 252 | + headers: { 'X-Env': 'prod' }, |
| 253 | + }), |
| 254 | + ], |
| 255 | + }, |
| 256 | + }); |
| 257 | + await bootstrapDeclaredWebhooks(engine as any, null); |
| 258 | + |
| 259 | + const realtime = new FakeRealtime(); |
| 260 | + const calls: any[] = []; |
| 261 | + const enqueue: HttpEnqueueFn = async (input) => { |
| 262 | + calls.push(input); |
| 263 | + return 'id'; |
| 264 | + }; |
| 265 | + const ae = new AutoEnqueuer(engine as any, realtime as any, enqueue, { refreshIntervalMs: 0 }); |
| 266 | + await ae.start(); |
| 267 | + |
| 268 | + await realtime.publish({ |
| 269 | + type: 'data.record.created', |
| 270 | + object: 'showcase_task', |
| 271 | + payload: { recordId: 't-1' }, |
| 272 | + timestamp: '2026-05-24T00:00:00.000Z', |
| 273 | + }); |
| 274 | + await flush(); |
| 275 | + |
| 276 | + expect(calls).toHaveLength(1); |
| 277 | + expect(calls[0].url).toBe('https://hooks.example/task'); |
| 278 | + // headers + secret came from the definition_json envelope the bridge wrote. |
| 279 | + expect(calls[0].signingSecret).toBe('shh'); |
| 280 | + expect(calls[0].headers).toEqual({ 'X-Env': 'prod' }); |
| 281 | + await ae.stop(); |
| 282 | + }); |
| 283 | +}); |
0 commit comments