|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0142 D6 as amended; ruling record 6599731698 on #29604, letter B] The |
| 5 | + * ADR-0105 D9 cross-organization read filter is a read behind a `position` or |
| 6 | + * `team` approver as much as the directory expansion is. When it fails, the |
| 7 | + * node refuses to open with `ApproverDirectoryUnreadableError`, naming THAT |
| 8 | + * read — on the static resolver (its `bounded` filter) and on an `expression` |
| 9 | + * approver's `resolveAs` values alike. |
| 10 | + * |
| 11 | + * `filterApproversWhoCanRead` answers its own failed `sys_member` read by |
| 12 | + * keeping the slate, so the only way to make it fail is to make it reject: the |
| 13 | + * module is wrapped here with a pass-through spy, and each case says when it |
| 14 | + * rejects. Same scenario as `approver-cross-org.integration.test.ts`: a Plant A |
| 15 | + * request routed to the group CFO. |
| 16 | + */ |
| 17 | + |
| 18 | +import { describe, it, expect, beforeEach, vi } from 'vitest'; |
| 19 | +import { ApprovalService, ApproverDirectoryUnreadableError } from './approval-service.js'; |
| 20 | +import { filterApproversWhoCanRead } from './approver-org-scope.js'; |
| 21 | + |
| 22 | +vi.mock('./approver-org-scope.js', async (importOriginal) => { |
| 23 | + const actual = await importOriginal<typeof import('./approver-org-scope.js')>(); |
| 24 | + return { ...actual, filterApproversWhoCanRead: vi.fn(actual.filterApproversWhoCanRead) }; |
| 25 | +}); |
| 26 | + |
| 27 | +interface Row { [k: string]: any } |
| 28 | + |
| 29 | +function makeEngine(seed: Record<string, Row[]>) { |
| 30 | + const tables: Record<string, Row[]> = { ...seed }; |
| 31 | + const ensure = (n: string) => (tables[n] ??= []); |
| 32 | + const matches = (row: Row, filter: any): boolean => { |
| 33 | + if (!filter || typeof filter !== 'object') return true; |
| 34 | + for (const [k, v] of Object.entries(filter)) { |
| 35 | + if (k === '$or') { |
| 36 | + if (!(v as any[]).some((sub) => matches(row, sub))) return false; |
| 37 | + continue; |
| 38 | + } |
| 39 | + if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`); |
| 40 | + const rv = row[k]; |
| 41 | + if (v != null && typeof v === 'object' && '$in' in (v as any)) { |
| 42 | + if (!(v as any).$in.includes(rv)) return false; |
| 43 | + continue; |
| 44 | + } |
| 45 | + if (v != null && typeof v === 'object' && '$ne' in (v as any)) { |
| 46 | + if (rv === (v as any).$ne) return false; |
| 47 | + continue; |
| 48 | + } |
| 49 | + if (rv !== v) return false; |
| 50 | + } |
| 51 | + return true; |
| 52 | + }; |
| 53 | + return { |
| 54 | + _tables: tables, |
| 55 | + async find(object: string, options?: any) { |
| 56 | + return ensure(object).filter((r) => matches(r, options?.filter ?? options?.where)); |
| 57 | + }, |
| 58 | + async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; }, |
| 59 | + async update() { return {}; }, |
| 60 | + async delete() { return {}; }, |
| 61 | + async count(object: string) { return ensure(object).length; }, |
| 62 | + registerHook() { /* no-op */ }, |
| 63 | + unregisterHooksByPackage() { /* no-op */ }, |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +const SEED = () => ({ |
| 68 | + sys_user: [{ id: 'u_cfo' }], |
| 69 | + sys_organization: [ |
| 70 | + { id: 'o_group', slug: 'acme-group', parent_organization_id: null }, |
| 71 | + { id: 'o_plant', slug: 'acme-plant-a', parent_organization_id: 'o_group' }, |
| 72 | + ], |
| 73 | + sys_user_position: [{ id: 'up1', user_id: 'u_cfo', position: 'cfo', organization_id: 'o_group' }], |
| 74 | + sys_member: [{ id: 'm1', user_id: 'u_cfo', organization_id: 'o_plant', role: 'member' }], |
| 75 | +}); |
| 76 | + |
| 77 | +const CTX = { userId: 'u_submitter', organizationId: 'o_plant', positions: [], permissions: [] } as any; |
| 78 | + |
| 79 | +const node = (approver: Record<string, unknown>) => ({ |
| 80 | + object: 'purchase_order', |
| 81 | + recordId: 'po1', |
| 82 | + runId: 'run_1', |
| 83 | + nodeId: 'group_signoff', |
| 84 | + flowName: 'po_approval', |
| 85 | + config: { approvers: [approver], behavior: 'unanimous' as const, lockRecord: false, onEmptyApprovers: 'admin_rescue' as const }, |
| 86 | + record: { id: 'po1', signoff_position: 'cfo' }, |
| 87 | +}); |
| 88 | + |
| 89 | +const STATIC_CFO = { type: 'position', value: 'cfo', organization: '$root' }; |
| 90 | +const EXPRESSION_CFO = { type: 'expression', value: 'trigger.signoff_position', resolveAs: 'position', organization: '$root' }; |
| 91 | + |
| 92 | +const FILTER_READ = /^the cross-organization read filter \(who among the people of organization 'o_group' can read this request of organization 'o_plant'\) could not be read: membership read filter down\.$/; |
| 93 | + |
| 94 | +describe('ADR-0142 D6 — a failed cross-organization read filter refuses the node, named as that read (#29604)', () => { |
| 95 | + let engine: ReturnType<typeof makeEngine>; |
| 96 | + let svc: ApprovalService; |
| 97 | + let n = 0; |
| 98 | + |
| 99 | + beforeEach(() => { |
| 100 | + vi.mocked(filterApproversWhoCanRead).mockClear(); |
| 101 | + engine = makeEngine(SEED()); |
| 102 | + n = 0; |
| 103 | + svc = new ApprovalService({ |
| 104 | + engine: engine as any, |
| 105 | + clock: { now: () => new Date(1767000000000 + (n++) * 1000) }, |
| 106 | + tenancyPosture: () => 'group', |
| 107 | + }); |
| 108 | + }); |
| 109 | + |
| 110 | + const refusalOf = (p: Promise<unknown>) => p.then(() => null, (e: unknown) => e); |
| 111 | + const filterDown = () => vi.mocked(filterApproversWhoCanRead).mockRejectedValueOnce(new Error('membership read filter down')); |
| 112 | + |
| 113 | + for (const [label, approver, type, value] of [ |
| 114 | + ['a static position approver', STATIC_CFO, 'position', 'cfo'], |
| 115 | + ['an expression approver resolving as position', EXPRESSION_CFO, 'position', 'cfo'], |
| 116 | + ] as const) { |
| 117 | + it(`control — ${label} reaches the group CFO through the filter`, async () => { |
| 118 | + const req = await svc.openNodeRequest(node(approver), CTX) as any; |
| 119 | + expect(req.pending_approvers).toEqual(['u_cfo']); |
| 120 | + expect(filterApproversWhoCanRead).toHaveBeenCalledTimes(1); |
| 121 | + }); |
| 122 | + |
| 123 | + it(`${label}: the filter's failure refuses the node and names the filter's read`, async () => { |
| 124 | + filterDown(); |
| 125 | + const refusal = await refusalOf(svc.openNodeRequest(node(approver), CTX)); |
| 126 | + |
| 127 | + expect(refusal).toBeInstanceOf(ApproverDirectoryUnreadableError); |
| 128 | + expect(refusal).toMatchObject({ approverType: type, approverValue: value, retryable: true }); |
| 129 | + expect((refusal as ApproverDirectoryUnreadableError).failedRead).toMatch(FILTER_READ); |
| 130 | + expect(engine._tables['sys_approval_request'] ?? []).toEqual([]); |
| 131 | + }); |
| 132 | + } |
| 133 | +}); |
0 commit comments