|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +/** |
| 3 | + * MEASUREMENT HARNESS for #10153 — it pins the CURRENT (defective) behaviour on |
| 4 | + * purpose, so the premise and the tiering question are reproducible rather than |
| 5 | + * argued. It is NOT the fix and it is NOT on a pull request. |
| 6 | + * |
| 7 | + * ⛔ Whoever implements the org screen must INVERT `PREMISE A` and `CLAUSE-2 (a)` |
| 8 | + * — they assert today's cross-org resolution, which is exactly what the fix |
| 9 | + * removes. `PREMISE B` / `B2` / `CLAUSE-2 (b)` describe the sibling treatment |
| 10 | + * and stay as they are. |
| 11 | + * |
| 12 | + * What it measures, all on one tree: |
| 13 | + * A — `manager` resolves a manager whose only `sys_member` row is in |
| 14 | + * another organization, into this organization's approver slate. |
| 15 | + * B/B2 — the sibling `position` expansion IS screened, and the screen is not |
| 16 | + * reject-everything (a same-org holder still resolves). |
| 17 | + * W — `team` is NOT screened either, which is why #10153's warrant |
| 18 | + * ("every sibling expansion is org-scoped") does not hold as stated. |
| 19 | + * C-a/b — the tiering question: today a sole cross-org `manager` approver |
| 20 | + * under `onEmptyApprovers: 'fail'` OPENS the request; a screened type |
| 21 | + * in the identical shape THROWS `NO_APPROVERS`. Applying the screen |
| 22 | + * therefore moves that input from accepted to refused. |
| 23 | + */ |
| 24 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 25 | +import { ApprovalService } from './approval-service.js'; |
| 26 | + |
| 27 | +function makeFakeEngine() { |
| 28 | + const tables: Record<string, any[]> = {}; |
| 29 | + const ensure = (n: string) => (tables[n] ??= []); |
| 30 | + function matches(row: any, filter: any): boolean { |
| 31 | + if (!filter || typeof filter !== 'object') return true; |
| 32 | + for (const [k, v] of Object.entries(filter)) { |
| 33 | + if (k === '$or') { if (!(v as any[]).some(s => matches(row, s))) return false; continue; } |
| 34 | + if (k === '$and') { if (!(v as any[]).every(s => matches(row, s))) return false; continue; } |
| 35 | + const rv = row[k]; |
| 36 | + if (v != null && typeof v === 'object' && '$in' in (v as any)) { |
| 37 | + if (!(v as any).$in.includes(rv)) return false; continue; |
| 38 | + } |
| 39 | + if (v != null && typeof v === 'object' && '$ne' in (v as any)) { |
| 40 | + if (rv === (v as any).$ne) return false; continue; |
| 41 | + } |
| 42 | + if (rv !== v) return false; |
| 43 | + } |
| 44 | + return true; |
| 45 | + } |
| 46 | + return { |
| 47 | + _tables: tables, |
| 48 | + async find(object: string, options?: any) { |
| 49 | + const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where)); |
| 50 | + return rows.slice(0, options?.limit ?? 1000); |
| 51 | + }, |
| 52 | + async insert(object: string, data: any) { ensure(object).push({ ...data }); return { ...data }; }, |
| 53 | + async update(object: string, idOrData: any, _opts?: any) { |
| 54 | + const data = typeof idOrData === 'object' ? idOrData : _opts; |
| 55 | + const id = typeof idOrData === 'object' ? idOrData.id : idOrData; |
| 56 | + const t = ensure(object); 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() { return {}; }, |
| 61 | + registerHook() {}, unregisterHooksByPackage() { return 0; }, async fire() {}, |
| 62 | + }; |
| 63 | +} |
| 64 | + |
| 65 | +const ORG_A = 'org_a'; |
| 66 | +const CTX_A = { userId: 'u_sub', organizationId: ORG_A, positions: [], permissions: [] } as any; |
| 67 | + |
| 68 | +function input(approvers: any[], configExtra: Record<string, any> = {}) { |
| 69 | + return { |
| 70 | + object: 'opportunity', recordId: 'opp1', runId: 'run_1', nodeId: 'approve_step', |
| 71 | + flowName: 'deal_approval', |
| 72 | + config: { approvers, behavior: 'first_response' as const, lockRecord: false, ...configExtra }, |
| 73 | + record: { id: 'opp1', owner_id: 'u_sub', amount: 100 }, |
| 74 | + }; |
| 75 | +} |
| 76 | + |
| 77 | +describe('#10153 probe', () => { |
| 78 | + let engine: ReturnType<typeof makeFakeEngine>; |
| 79 | + let svc: ApprovalService; |
| 80 | + let n = 0; |
| 81 | + |
| 82 | + beforeEach(() => { |
| 83 | + engine = makeFakeEngine(); |
| 84 | + n = 0; |
| 85 | + svc = new ApprovalService({ |
| 86 | + engine: engine as any, |
| 87 | + clock: { now: () => new Date(new Date('2026-01-15T10:00:00Z').getTime() + (n++) * 1000) }, |
| 88 | + }); |
| 89 | + // Directory: submitter in org_a; his manager is a member of org_b ONLY. |
| 90 | + engine._tables['sys_user'] = [ |
| 91 | + { id: 'u_sub', manager_id: 'u_mgr_b' }, |
| 92 | + { id: 'u_mgr_b', manager_id: null }, |
| 93 | + ]; |
| 94 | + engine._tables['sys_member'] = [ |
| 95 | + { id: 'm1', user_id: 'u_sub', organization_id: ORG_A, role: 'member' }, |
| 96 | + { id: 'm2', user_id: 'u_mgr_b', organization_id: 'org_b', role: 'member' }, |
| 97 | + ]; |
| 98 | + // Sibling directory: the only `cfo` holder is in org_b. |
| 99 | + engine._tables['sys_user_position'] = [ |
| 100 | + { id: 'p1', user_id: 'u_pos_b', position: 'cfo', organization_id: 'org_b' }, |
| 101 | + ]; |
| 102 | + }); |
| 103 | + |
| 104 | + it('PREMISE A — `manager` resolves ACROSS the org boundary (unscreened)', async () => { |
| 105 | + const req = await svc.openNodeRequest(input([{ type: 'manager' }]), CTX_A); |
| 106 | + console.log('[PROBE A] request org =', req.organization_id, 'pending_approvers =', JSON.stringify(req.pending_approvers)); |
| 107 | + expect(req.pending_approvers).toEqual(['u_mgr_b']); |
| 108 | + }); |
| 109 | + |
| 110 | + it('PREMISE B — sibling `position` IS screened to the request org (same tree)', async () => { |
| 111 | + const req = await svc.openNodeRequest(input([{ type: 'position', value: 'cfo' }]), CTX_A); |
| 112 | + console.log('[PROBE B] pending_approvers =', JSON.stringify(req.pending_approvers)); |
| 113 | + expect(req.pending_approvers).toEqual(['position:cfo']); |
| 114 | + }); |
| 115 | + |
| 116 | + it('PREMISE B2 — same-org `position` holder DOES resolve (screen is not reject-everything)', async () => { |
| 117 | + engine._tables['sys_user_position'].push({ id: 'p2', user_id: 'u_pos_a', position: 'cfo', organization_id: ORG_A }); |
| 118 | + const req = await svc.openNodeRequest(input([{ type: 'position', value: 'cfo' }]), CTX_A); |
| 119 | + console.log('[PROBE B2] pending_approvers =', JSON.stringify(req.pending_approvers)); |
| 120 | + expect(req.pending_approvers).toEqual(['u_pos_a']); |
| 121 | + }); |
| 122 | + |
| 123 | + it('CLAUSE-2 (a) — TODAY: sole cross-org `manager` + onEmptyApprovers:fail SUCCEEDS', async () => { |
| 124 | + const req = await svc.openNodeRequest(input([{ type: 'manager' }], { onEmptyApprovers: 'fail' }), CTX_A); |
| 125 | + console.log('[PROBE C-a] opened OK, status =', req.status, 'approvers =', JSON.stringify(req.pending_approvers)); |
| 126 | + expect(req.status).toBe('pending'); |
| 127 | + }); |
| 128 | + |
| 129 | + it('CLAUSE-2 (b) — a SCREENED sibling in the same shape THROWS NO_APPROVERS', async () => { |
| 130 | + let err: any = null; |
| 131 | + try { |
| 132 | + await svc.openNodeRequest(input([{ type: 'position', value: 'cfo' }], { onEmptyApprovers: 'fail' }), CTX_A); |
| 133 | + } catch (e) { err = e; } |
| 134 | + console.log('[PROBE C-b] threw =', err ? String(err.message).slice(0, 90) : 'NOTHING'); |
| 135 | + expect(err).toBeTruthy(); |
| 136 | + }); |
| 137 | + |
| 138 | + it('WARRANT — sibling `team` is NOT org-screened either (cross-org team resolves)', async () => { |
| 139 | + engine._tables['sys_team'] = [{ id: 'team_b', name: 'B team', organization_id: 'org_b' }]; |
| 140 | + engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_b', user_id: 'u_team_b' }]; |
| 141 | + const req = await svc.openNodeRequest(input([{ type: 'team', value: 'team_b' }]), CTX_A); |
| 142 | + console.log('[PROBE W] org_a request, org_b team -> pending_approvers =', JSON.stringify(req.pending_approvers)); |
| 143 | + expect(req.pending_approvers).toEqual(['u_team_b']); |
| 144 | + }); |
| 145 | +}); |
0 commit comments