|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#2926 ③] Boot backfill of sharing-rule grants. |
| 5 | + * |
| 6 | + * Rule grants are materialized by write hooks, which deliberately skip |
| 7 | + * `isSystem` writes (rule-hooks.ts) — so records created by the boot-time |
| 8 | + * seed loader (always `isSystem`) never produced `sys_record_share` rows: |
| 9 | + * demo data shipping with matching sharing rules was broken out of the box |
| 10 | + * until an admin "touched" each record at runtime. `backfillRuleGrants` |
| 11 | + * reconciles every active rule once per boot, idempotently. |
| 12 | + */ |
| 13 | + |
| 14 | +import { describe, it, expect, beforeEach, vi } from 'vitest'; |
| 15 | +import { SharingService } from './sharing-service.js'; |
| 16 | +import { SharingRuleService } from './sharing-rule-service.js'; |
| 17 | +import { backfillRuleGrants } from './sharing-plugin.js'; |
| 18 | + |
| 19 | +interface Row { [k: string]: any } |
| 20 | + |
| 21 | +const SYS = { isSystem: true } as any; |
| 22 | + |
| 23 | +function makeEngine() { |
| 24 | + const tables: Record<string, Row[]> = {}; |
| 25 | + const ensure = (n: string) => (tables[n] ??= []); |
| 26 | + function matches(row: Row, f: any): boolean { |
| 27 | + if (!f || typeof f !== 'object') return true; |
| 28 | + if (Array.isArray(f.$or)) return f.$or.some((x: any) => matches(row, x)); |
| 29 | + if (Array.isArray(f.$and)) return f.$and.every((x: any) => matches(row, x)); |
| 30 | + for (const [k, v] of Object.entries(f)) { |
| 31 | + if (k === '$or' || k === '$and') continue; |
| 32 | + const rv = row[k]; |
| 33 | + if (v != null && typeof v === 'object' && '$in' in (v as any)) { |
| 34 | + if (!(v as any).$in.includes(rv)) return false; |
| 35 | + continue; |
| 36 | + } |
| 37 | + if (v != null && typeof v === 'object' && '$gte' in (v as any)) { |
| 38 | + if (!(rv >= (v as any).$gte)) return false; |
| 39 | + continue; |
| 40 | + } |
| 41 | + if (rv !== v) return false; |
| 42 | + } |
| 43 | + return true; |
| 44 | + } |
| 45 | + return { |
| 46 | + _tables: tables, |
| 47 | + getSchema() { return undefined; }, |
| 48 | + async find(o: string, opts?: any) { |
| 49 | + const f = opts?.filter ?? opts?.where; |
| 50 | + return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000); |
| 51 | + }, |
| 52 | + async insert(o: string, data: any) { const row = { ...data }; ensure(o).push(row); return row; }, |
| 53 | + async update(o: string, idOrData: any, dataOrOpts?: any) { |
| 54 | + const data = typeof idOrData === 'object' ? idOrData : dataOrOpts; |
| 55 | + const id = typeof idOrData === 'object' ? idOrData.id : idOrData; |
| 56 | + const t = ensure(o); const i = t.findIndex((r) => r.id === id); |
| 57 | + if (i >= 0) t[i] = { ...t[i], ...data }; |
| 58 | + return t[i]; |
| 59 | + }, |
| 60 | + async delete(o: string, opts?: any) { |
| 61 | + const t = ensure(o); const where = opts?.where ?? {}; |
| 62 | + for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1); |
| 63 | + return { ok: true }; |
| 64 | + }, |
| 65 | + }; |
| 66 | +} |
| 67 | + |
| 68 | +describe('backfillRuleGrants (#2926 ③ — seed rows materialize at boot)', () => { |
| 69 | + let engine: ReturnType<typeof makeEngine>; |
| 70 | + let sharing: SharingService; |
| 71 | + let rules: SharingRuleService; |
| 72 | + |
| 73 | + beforeEach(async () => { |
| 74 | + engine = makeEngine(); |
| 75 | + sharing = new SharingService({ engine: engine as any }); |
| 76 | + rules = new SharingRuleService({ engine: engine as any, sharing }); |
| 77 | + // Seed-loader analog: records written directly (isSystem path) — the |
| 78 | + // write hooks never ran, so sys_record_share is empty. |
| 79 | + engine._tables.showcase_inquiry = [ |
| 80 | + { id: 'inq_new', status: 'new', owner_id: 'priya' }, |
| 81 | + { id: 'inq_won', status: 'won', owner_id: 'priya' }, |
| 82 | + ]; |
| 83 | + await rules.defineRule({ |
| 84 | + name: 'new_inquiries_to_wes', label: 'New inquiries → wes', object: 'showcase_inquiry', |
| 85 | + criteria: { status: 'new' }, |
| 86 | + recipientType: 'user', recipientId: 'wes', accessLevel: 'read', |
| 87 | + }, SYS); |
| 88 | + }); |
| 89 | + |
| 90 | + it('materializes grants for seed records that match an active rule', async () => { |
| 91 | + expect(engine._tables.sys_record_share ?? []).toHaveLength(0); |
| 92 | + const active = await rules.listRules({ activeOnly: true }, SYS); |
| 93 | + const reconciled = await backfillRuleGrants(rules, active); |
| 94 | + expect(reconciled).toBe(1); |
| 95 | + const shares = engine._tables.sys_record_share ?? []; |
| 96 | + expect(shares).toHaveLength(1); |
| 97 | + expect(shares[0]).toMatchObject({ record_id: 'inq_new', recipient_id: 'wes' }); |
| 98 | + }); |
| 99 | + |
| 100 | + it('is idempotent across repeated boots (no duplicate grants)', async () => { |
| 101 | + const active = await rules.listRules({ activeOnly: true }, SYS); |
| 102 | + await backfillRuleGrants(rules, active); |
| 103 | + await backfillRuleGrants(rules, active); |
| 104 | + expect(engine._tables.sys_record_share ?? []).toHaveLength(1); |
| 105 | + }); |
| 106 | + |
| 107 | + it('one broken rule does not block the others (best-effort per rule)', async () => { |
| 108 | + await rules.defineRule({ |
| 109 | + name: 'zzz_broken', label: 'Broken', object: 'showcase_inquiry', |
| 110 | + criteria: { status: 'new' }, |
| 111 | + recipientType: 'user', recipientId: 'someone', accessLevel: 'read', |
| 112 | + }, SYS); |
| 113 | + const active = await rules.listRules({ activeOnly: true }, SYS); |
| 114 | + // Blow up evaluation of the broken rule only. |
| 115 | + const realEvaluate = rules.evaluateRule.bind(rules); |
| 116 | + vi.spyOn(rules, 'evaluateRule').mockImplementation(async (idOrName: string, context: any) => { |
| 117 | + const rule = await rules.getRule(idOrName, context); |
| 118 | + if (rule?.name === 'zzz_broken') throw new Error('boom'); |
| 119 | + return realEvaluate(idOrName, context); |
| 120 | + }); |
| 121 | + const warn = vi.fn(); |
| 122 | + const reconciled = await backfillRuleGrants(rules, active, { warn }); |
| 123 | + expect(reconciled).toBe(1); |
| 124 | + expect(warn).toHaveBeenCalledOnce(); |
| 125 | + // The healthy rule still materialized. |
| 126 | + expect((engine._tables.sys_record_share ?? []).some((s) => s.record_id === 'inq_new')).toBe(true); |
| 127 | + }); |
| 128 | +}); |
0 commit comments