@objectstack/plugin-sharing 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, beforeEach } from 'vitest';
4
+ import { SharingService } from './sharing-service.js';
5
+ import { SharingRuleService } from './sharing-rule-service.js';
6
+ import { TeamGraphService, expandPrincipal } from './team-graph.js';
7
+ import { DepartmentGraphService } from './department-graph.js';
8
+
9
+ interface Row { [k: string]: any }
10
+
11
+ function makeEngine() {
12
+ const tables: Record<string, Row[]> = {};
13
+ const ensure = (n: string) => (tables[n] ??= []);
14
+ function matches(row: Row, f: any): boolean {
15
+ if (!f || typeof f !== 'object') return true;
16
+ if (Array.isArray(f.$or)) return f.$or.some((x: any) => matches(row, x));
17
+ if (Array.isArray(f.$and)) return f.$and.every((x: any) => matches(row, x));
18
+ for (const [k, v] of Object.entries(f)) {
19
+ if (k === '$or' || k === '$and') continue;
20
+ const rv = row[k];
21
+ if (v != null && typeof v === 'object' && '$in' in (v as any)) {
22
+ if (!(v as any).$in.includes(rv)) return false;
23
+ continue;
24
+ }
25
+ if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
26
+ if (rv === (v as any).$ne) return false;
27
+ continue;
28
+ }
29
+ if (v != null && typeof v === 'object' && '$gte' in (v as any)) {
30
+ if (!(rv >= (v as any).$gte)) return false;
31
+ continue;
32
+ }
33
+ if (rv !== v) return false;
34
+ }
35
+ return true;
36
+ }
37
+ return {
38
+ _tables: tables,
39
+ getSchema() { return undefined; },
40
+ async find(o: string, opts?: any) {
41
+ const f = opts?.filter ?? opts?.where;
42
+ return ensure(o).filter(r => matches(r, f)).slice(0, opts?.limit ?? 10000);
43
+ },
44
+ async insert(o: string, data: any) { const row = { ...data }; ensure(o).push(row); return row; },
45
+ async update(o: string, idOrData: any, dataOrOpts?: any) {
46
+ const data = typeof idOrData === 'object' ? idOrData : dataOrOpts;
47
+ const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
48
+ const t = ensure(o); const i = t.findIndex(r => r.id === id);
49
+ if (i >= 0) t[i] = { ...t[i], ...data };
50
+ return t[i];
51
+ },
52
+ async delete(o: string, opts?: any) {
53
+ const t = ensure(o); const where = opts?.where ?? {};
54
+ for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
55
+ return { ok: true };
56
+ },
57
+ };
58
+ }
59
+
60
+ describe('TeamGraphService (flat — better-auth sys_team)', () => {
61
+ let engine: ReturnType<typeof makeEngine>;
62
+ beforeEach(() => {
63
+ engine = makeEngine();
64
+ // FLAT teams — no parent_team_id; cross-org leak guard
65
+ engine._tables.sys_team = [
66
+ { id: 'eu_sales', name: 'eu_sales', organization_id: 'org1' },
67
+ { id: 'us_sales', name: 'us_sales', organization_id: 'org1' },
68
+ { id: 'foreign', name: 'foreign', organization_id: 'org2' },
69
+ ];
70
+ engine._tables.sys_team_member = [
71
+ { id: 'tm1', team_id: 'eu_sales', user_id: 'alice' },
72
+ { id: 'tm2', team_id: 'eu_sales', user_id: 'bob' },
73
+ { id: 'tm3', team_id: 'us_sales', user_id: 'carol' },
74
+ ];
75
+ engine._tables.sys_member = [
76
+ { id: 'm1', organization_id: 'org1', user_id: 'alice', role: 'sales_manager' },
77
+ { id: 'm2', organization_id: 'org1', user_id: 'bob', role: 'sales_rep' },
78
+ { id: 'm3', organization_id: 'org2', user_id: 'eve', role: 'sales_manager' },
79
+ ];
80
+ engine._tables.sys_user = [
81
+ { id: 'alice', manager_id: 'bob' },
82
+ { id: 'bob', manager_id: 'carol' },
83
+ { id: 'carol', manager_id: null },
84
+ ];
85
+ });
86
+
87
+ it('expandUsers returns flat members (no hierarchy walk)', async () => {
88
+ const g = new TeamGraphService({ engine: engine as any, organizationId: 'org1' });
89
+ expect((await g.expandUsers('eu_sales')).sort()).toEqual(['alice', 'bob']);
90
+ expect(await g.expandUsers('us_sales')).toEqual(['carol']);
91
+ });
92
+
93
+ it('expandRoleUsers scopes by organization', async () => {
94
+ const g = new TeamGraphService({ engine: engine as any, organizationId: 'org1' });
95
+ expect((await g.expandRoleUsers('sales_manager')).sort()).toEqual(['alice']);
96
+ });
97
+
98
+ it('managerOf walks chain', async () => {
99
+ const g = new TeamGraphService({ engine: engine as any, organizationId: 'org1' });
100
+ expect(await g.managerOf('alice')).toEqual('bob');
101
+ expect(await g.managerOf('carol')).toBeNull();
102
+ });
103
+
104
+ it('expandPrincipal helper dispatches correctly', async () => {
105
+ const t = new TeamGraphService({ engine: engine as any, organizationId: 'org1' });
106
+ expect(await expandPrincipal({ type: 'user', value: 'x' }, { team: t, organizationId: 'org1' })).toEqual(['x']);
107
+ expect((await expandPrincipal({ type: 'team', value: 'eu_sales' }, { team: t, organizationId: 'org1' })).sort()).toEqual(['alice', 'bob']);
108
+ expect((await expandPrincipal({ type: 'role', value: 'sales_manager' }, { team: t, organizationId: 'org1' })).sort()).toEqual(['alice']);
109
+ expect(await expandPrincipal({ type: 'manager', value: 'owner_id', record: { owner_id: 'alice' } }, { team: t, organizationId: 'org1' })).toEqual(['bob']);
110
+ expect(await expandPrincipal({ type: 'queue', value: 'q1' }, { team: t, organizationId: 'org1' })).toEqual(['queue:q1']);
111
+ // department without a dept graph instance falls back to literal
112
+ expect(await expandPrincipal({ type: 'department', value: 'emea' }, { team: t, organizationId: 'org1' })).toEqual(['department:emea']);
113
+ });
114
+ });
115
+
116
+ describe('DepartmentGraphService (recursive sys_department)', () => {
117
+ let engine: ReturnType<typeof makeEngine>;
118
+ beforeEach(() => {
119
+ engine = makeEngine();
120
+ // Hierarchy: emea → emea_sales → emea_sales_uk ; emea_marketing
121
+ engine._tables.sys_department = [
122
+ { id: 'emea', name: 'EMEA', parent_department_id: null, organization_id: 'org1', active: true },
123
+ { id: 'emea_sales', name: 'EMEA Sales', parent_department_id: 'emea', organization_id: 'org1', active: true },
124
+ { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_department_id: 'emea_sales', organization_id: 'org1', active: true },
125
+ { id: 'emea_marketing', name: 'EMEA Marketing', parent_department_id: 'emea', organization_id: 'org1', active: true },
126
+ // Inactive subtree — must not contribute
127
+ { id: 'emea_legacy', name: 'EMEA Legacy', parent_department_id: 'emea', organization_id: 'org1', active: false },
128
+ // Foreign tenant — must not leak
129
+ { id: 'foreign', name: 'Foreign', parent_department_id: 'emea', organization_id: 'org2', active: true },
130
+ ];
131
+ engine._tables.sys_department_member = [
132
+ { id: 'dm1', department_id: 'emea_sales_uk', user_id: 'alice' },
133
+ { id: 'dm2', department_id: 'emea_sales', user_id: 'bob' },
134
+ { id: 'dm3', department_id: 'emea_marketing', user_id: 'carol' },
135
+ { id: 'dm4', department_id: 'emea_legacy', user_id: 'ghost' },
136
+ ];
137
+ });
138
+
139
+ it('descendants walks the active hierarchy', async () => {
140
+ const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' });
141
+ expect((await d.descendants('emea')).sort()).toEqual(['emea', 'emea_marketing', 'emea_sales', 'emea_sales_uk']);
142
+ });
143
+
144
+ it('expandUsers returns members of all descendant departments', async () => {
145
+ const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' });
146
+ expect((await d.expandUsers('emea')).sort()).toEqual(['alice', 'bob', 'carol']);
147
+ });
148
+
149
+ it('expandUsers of leaf returns just leaf members', async () => {
150
+ const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' });
151
+ expect(await d.expandUsers('emea_sales_uk')).toEqual(['alice']);
152
+ });
153
+
154
+ it('inactive subtree contributes no members', async () => {
155
+ const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' });
156
+ expect(await d.expandUsers('emea_legacy')).toEqual([]);
157
+ });
158
+
159
+ it('cross-tenant lookup is blocked by org scope', async () => {
160
+ const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' });
161
+ // 'foreign' is in org2 — descendants from emea should not include it
162
+ const desc = await d.descendants('emea');
163
+ expect(desc).not.toContain('foreign');
164
+ });
165
+
166
+ it('headOf returns manager_user_id (when set)', async () => {
167
+ engine._tables.sys_department[1].manager_user_id = 'alice';
168
+ const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' });
169
+ expect(await d.headOf('emea_sales')).toEqual('alice');
170
+ expect(await d.headOf('emea_marketing')).toBeNull();
171
+ });
172
+ });
173
+
174
+ describe('SharingRuleService', () => {
175
+ let engine: ReturnType<typeof makeEngine>;
176
+ let sharing: SharingService;
177
+ let rules: SharingRuleService;
178
+ const SYS = { isSystem: true, organizationId: 'org1' } as any;
179
+
180
+ beforeEach(() => {
181
+ engine = makeEngine();
182
+ // Seed: 3 opportunities — 2 high-value, 1 low.
183
+ engine._tables.opportunity = [
184
+ { id: 'opp1', name: 'Big1', amount: 200000, owner_id: 'someone' },
185
+ { id: 'opp2', name: 'Big2', amount: 150000, owner_id: 'someone' },
186
+ { id: 'opp3', name: 'Small', amount: 5000, owner_id: 'someone' },
187
+ ];
188
+ engine._tables.sys_team = [
189
+ { id: 'sales', name: 'sales', organization_id: 'org1' },
190
+ ];
191
+ engine._tables.sys_team_member = [
192
+ { id: 'tm1', team_id: 'sales', user_id: 'alice' },
193
+ { id: 'tm2', team_id: 'sales', user_id: 'bob' },
194
+ ];
195
+ // Department hierarchy: emea_sales (Alice) → emea_sales_uk (Bob)
196
+ engine._tables.sys_department = [
197
+ { id: 'emea_sales', name: 'EMEA Sales', parent_department_id: null, organization_id: 'org1', active: true },
198
+ { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_department_id: 'emea_sales', organization_id: 'org1', active: true },
199
+ ];
200
+ engine._tables.sys_department_member = [
201
+ { id: 'dm1', department_id: 'emea_sales', user_id: 'alice' },
202
+ { id: 'dm2', department_id: 'emea_sales_uk', user_id: 'bob' },
203
+ ];
204
+ sharing = new SharingService({ engine: engine as any });
205
+ rules = new SharingRuleService({ engine: engine as any, sharing });
206
+ });
207
+
208
+ it('defineRule creates a new rule', async () => {
209
+ const r = await rules.defineRule({
210
+ name: 'high_value', label: 'High value', object: 'opportunity',
211
+ criteria: { amount: { $gte: 100000 } },
212
+ recipientType: 'team', recipientId: 'sales', accessLevel: 'read',
213
+ }, SYS);
214
+ expect(r.id).toBeDefined();
215
+ expect(r.criteria).toEqual({ amount: { $gte: 100000 } });
216
+ expect(engine._tables.sys_sharing_rule).toHaveLength(1);
217
+ });
218
+
219
+ it('defineRule upserts on duplicate name within org', async () => {
220
+ await rules.defineRule({ name: 'x', label: 'X', object: 'opportunity', recipientType: 'user', recipientId: 'a' }, SYS);
221
+ await rules.defineRule({ name: 'x', label: 'X-renamed', object: 'opportunity', recipientType: 'user', recipientId: 'b' }, SYS);
222
+ expect(engine._tables.sys_sharing_rule).toHaveLength(1);
223
+ expect(engine._tables.sys_sharing_rule[0].label).toBe('X-renamed');
224
+ expect(engine._tables.sys_sharing_rule[0].recipient_id).toBe('b');
225
+ });
226
+
227
+ it('evaluateRule materialises grants for matching records × expanded users', async () => {
228
+ const r = await rules.defineRule({
229
+ name: 'hv', label: 'High value', object: 'opportunity',
230
+ criteria: { amount: { $gte: 100000 } },
231
+ recipientType: 'team', recipientId: 'sales', accessLevel: 'read',
232
+ }, SYS);
233
+ const res = await rules.evaluateRule(r.id, SYS);
234
+ expect(res.matchedRecords).toBe(2);
235
+ expect(res.expandedUsers).toBe(2);
236
+ expect(res.grantsCreated).toBe(4); // 2 records × 2 users
237
+ expect(engine._tables.sys_record_share).toHaveLength(4);
238
+ // Verify shape
239
+ const shares = engine._tables.sys_record_share;
240
+ expect(new Set(shares.map(s => s.record_id))).toEqual(new Set(['opp1', 'opp2']));
241
+ expect(new Set(shares.map(s => s.recipient_id))).toEqual(new Set(['alice', 'bob']));
242
+ expect(shares.every(s => s.source === 'rule' && s.source_id === r.id && s.access_level === 'read')).toBe(true);
243
+ });
244
+
245
+ it('evaluateRule reconciles — re-running with a narrower criteria revokes stale grants', async () => {
246
+ const r = await rules.defineRule({
247
+ name: 'hv', label: 'HV', object: 'opportunity',
248
+ criteria: { amount: { $gte: 100000 } },
249
+ recipientType: 'team', recipientId: 'sales',
250
+ }, SYS);
251
+ await rules.evaluateRule(r.id, SYS);
252
+ expect(engine._tables.sys_record_share).toHaveLength(4);
253
+
254
+ // Tighten criteria — now only opp1 (200k) qualifies.
255
+ await rules.defineRule({
256
+ name: 'hv', label: 'HV', object: 'opportunity',
257
+ criteria: { amount: { $gte: 175000 } },
258
+ recipientType: 'team', recipientId: 'sales',
259
+ }, SYS);
260
+ const res = await rules.evaluateRule(r.id, SYS);
261
+ expect(res.matchedRecords).toBe(1);
262
+ expect(res.grantsRevoked).toBe(2);
263
+ expect(engine._tables.sys_record_share).toHaveLength(2);
264
+ expect(engine._tables.sys_record_share.every(s => s.record_id === 'opp1')).toBe(true);
265
+ });
266
+
267
+ it('evaluateAllForRecord upserts when record newly matches', async () => {
268
+ const r = await rules.defineRule({
269
+ name: 'hv', label: 'HV', object: 'opportunity',
270
+ criteria: { amount: { $gte: 100000 } },
271
+ recipientType: 'team', recipientId: 'sales',
272
+ }, SYS);
273
+ const res = await rules.evaluateAllForRecord('opportunity', 'opp1', SYS);
274
+ expect(res[0].matchedRecords).toBe(1);
275
+ expect(res[0].grantsCreated).toBe(2);
276
+ expect(engine._tables.sys_record_share).toHaveLength(2);
277
+ });
278
+
279
+ it('evaluateAllForRecord revokes when record no longer matches', async () => {
280
+ const r = await rules.defineRule({
281
+ name: 'hv', label: 'HV', object: 'opportunity',
282
+ criteria: { amount: { $gte: 100000 } },
283
+ recipientType: 'team', recipientId: 'sales',
284
+ }, SYS);
285
+ await rules.evaluateRule(r.id, SYS);
286
+ // Drop opp1 below threshold
287
+ engine._tables.opportunity[0].amount = 5;
288
+ const res = await rules.evaluateAllForRecord('opportunity', 'opp1', SYS);
289
+ expect(res[0].grantsRevoked).toBe(2);
290
+ // Only opp2 grants remain
291
+ expect(engine._tables.sys_record_share.every(s => s.record_id === 'opp2')).toBe(true);
292
+ });
293
+
294
+ it('deleteRule drops rule + all its grants', async () => {
295
+ const r = await rules.defineRule({
296
+ name: 'hv', label: 'HV', object: 'opportunity',
297
+ criteria: { amount: { $gte: 100000 } },
298
+ recipientType: 'team', recipientId: 'sales',
299
+ }, SYS);
300
+ await rules.evaluateRule(r.id, SYS);
301
+ expect(engine._tables.sys_record_share.length).toBeGreaterThan(0);
302
+ await rules.deleteRule(r.id, SYS);
303
+ expect(engine._tables.sys_sharing_rule).toHaveLength(0);
304
+ expect(engine._tables.sys_record_share).toHaveLength(0);
305
+ });
306
+
307
+ it('inactive rule purges grants on evaluate', async () => {
308
+ const r = await rules.defineRule({
309
+ name: 'hv', label: 'HV', object: 'opportunity',
310
+ criteria: { amount: { $gte: 100000 } },
311
+ recipientType: 'team', recipientId: 'sales',
312
+ }, SYS);
313
+ await rules.evaluateRule(r.id, SYS);
314
+ expect(engine._tables.sys_record_share).toHaveLength(4);
315
+ await rules.defineRule({
316
+ name: 'hv', label: 'HV', object: 'opportunity',
317
+ criteria: { amount: { $gte: 100000 } },
318
+ recipientType: 'team', recipientId: 'sales', active: false,
319
+ }, SYS);
320
+ const res = await rules.evaluateRule(r.id, SYS);
321
+ expect(res.grantsRevoked).toBe(4);
322
+ expect(engine._tables.sys_record_share).toHaveLength(0);
323
+ });
324
+
325
+ it('listRules filters by object + activeOnly', async () => {
326
+ await rules.defineRule({ name: 'a', label: 'A', object: 'opportunity', recipientType: 'user', recipientId: 'x' }, SYS);
327
+ await rules.defineRule({ name: 'b', label: 'B', object: 'account', recipientType: 'user', recipientId: 'y' }, SYS);
328
+ await rules.defineRule({ name: 'c', label: 'C', object: 'opportunity', recipientType: 'user', recipientId: 'z', active: false }, SYS);
329
+ const opps = await rules.listRules({ object: 'opportunity' }, SYS);
330
+ expect(opps).toHaveLength(2);
331
+ const active = await rules.listRules({ object: 'opportunity', activeOnly: true }, SYS);
332
+ expect(active.map(r => r.name)).toEqual(['a']);
333
+ });
334
+
335
+ it('recipientType=department expands via the dept graph (BFS)', async () => {
336
+ const r = await rules.defineRule({
337
+ name: 'dept_rule', label: 'Dept Rule', object: 'opportunity',
338
+ criteria: { amount: { $gte: 100000 } },
339
+ recipientType: 'department', recipientId: 'emea_sales', accessLevel: 'read',
340
+ }, SYS);
341
+ const res = await rules.evaluateRule(r.id, SYS);
342
+ expect(res.matchedRecords).toBe(2); // opp1, opp2
343
+ expect(res.expandedUsers).toBe(2); // alice (emea_sales) + bob (emea_sales_uk descendant)
344
+ expect(res.grantsCreated).toBe(4);
345
+ expect(engine._tables.sys_record_share).toHaveLength(4);
346
+ expect(new Set(engine._tables.sys_record_share.map(s => s.recipient_id))).toEqual(new Set(['alice', 'bob']));
347
+ });
348
+ });