@@ -15,9 +15,17 @@ import {
1515 ApprovalService , ApproverDirectoryUnreadableError , REMIND_COOLDOWN_MS , ESCALATION_ENABLED_FLIP_CUTOFF_MS , SLA_ACTOR_ID ,
1616} from './approval-service.js' ;
1717import { bindApprovalLockHook , bindDelegationWriteGuard , unbindAllHooks } from './lifecycle-hooks.js' ;
18+ import { filterApproversWhoCanRead } from './approver-org-scope.js' ;
1819import { celEngine } from '@objectstack/formula' ;
1920import { SysApprovalRequest } from './sys-approval-request.object.js' ;
2021
22+ // [#29604] A pass-through spy on the ADR-0105 D9 read filter, so one describe
23+ // below can make it reject. Every other case runs the real filter through it.
24+ vi . mock ( './approver-org-scope.js' , async ( importOriginal ) => {
25+ const actual = await importOriginal < typeof import ( './approver-org-scope.js' ) > ( ) ;
26+ return { ...actual , filterApproversWhoCanRead : vi . fn ( actual . filterApproversWhoCanRead ) } ;
27+ } ) ;
28+
2129interface FakeRow { [ k : string ] : any }
2230
2331/**
@@ -4341,6 +4349,73 @@ describe('ApprovalService — a team, position or queue approver whose directory
43414349 } ) ;
43424350} ) ;
43434351
4352+ // [ADR-0142 D6 as amended; ruling record 6599731698 on #29604, letter B] The
4353+ // ADR-0105 D9 cross-organization read filter is a read behind a `position` or
4354+ // `team` approver as much as the directory expansion is. When it fails, the
4355+ // node refuses, naming THAT read, on the static resolver (its `bounded`
4356+ // filter) and on an `expression` approver's `resolveAs` values alike.
4357+ // `filterApproversWhoCanRead` answers its own failed `sys_member` read by
4358+ // keeping the slate, so the pass-through spy above is how it is made to fail.
4359+ // The scenario is `approver-cross-org.integration.test.ts`'s: a Plant A
4360+ // request routed to the group CFO.
4361+ describe ( 'ApprovalService — a failed cross-organization read filter refuses the node, named as that read (ADR-0142 D6)' , ( ) => {
4362+ const PLANT_CTX = { userId : 'u_submitter' , organizationId : 'o_plant' , positions : [ ] , permissions : [ ] } as any ;
4363+ const FILTER_READ = / ^ t h e c r o s s - o r g a n i z a t i o n r e a d f i l t e r \( w h o a m o n g t h e p e o p l e o f o r g a n i z a t i o n ' o _ g r o u p ' c a n r e a d t h i s r e q u e s t o f o r g a n i z a t i o n ' o _ p l a n t ' \) c o u l d n o t b e r e a d : m e m b e r s h i p r e a d f i l t e r d o w n \. $ / ;
4364+ let engine : ReturnType < typeof makeFakeEngine > ;
4365+ let svc : ApprovalService ;
4366+ let n = 0 ;
4367+
4368+ beforeEach ( ( ) => {
4369+ vi . mocked ( filterApproversWhoCanRead ) . mockClear ( ) ;
4370+ engine = makeFakeEngine ( ) ;
4371+ engine . _tables [ 'sys_user' ] . push ( { id : 'u_cfo' } ) ;
4372+ engine . _tables [ 'sys_organization' ] = [
4373+ { id : 'o_group' , slug : 'acme-group' , parent_organization_id : null } ,
4374+ { id : 'o_plant' , slug : 'acme-plant-a' , parent_organization_id : 'o_group' } ,
4375+ ] ;
4376+ engine . _tables [ 'sys_user_position' ] = [ { id : 'up1' , user_id : 'u_cfo' , position : 'cfo' , organization_id : 'o_group' } ] ;
4377+ engine . _tables [ 'sys_member' ] = [ { id : 'm1' , user_id : 'u_cfo' , organization_id : 'o_plant' , role : 'member' } ] ;
4378+ n = 0 ;
4379+ svc = new ApprovalService ( {
4380+ engine : engine as any ,
4381+ clock : { now : ( ) => new Date ( 1767000000000 + ( n ++ ) * 1000 ) } ,
4382+ tenancyPosture : ( ) => 'group' ,
4383+ } ) ;
4384+ } ) ;
4385+
4386+ const node = ( approver : Record < string , unknown > ) => ( {
4387+ object : 'purchase_order' ,
4388+ recordId : 'po1' ,
4389+ runId : 'run_1' ,
4390+ nodeId : 'group_signoff' ,
4391+ flowName : 'po_approval' ,
4392+ config : { approvers : [ approver as any ] , behavior : 'unanimous' as const , lockRecord : false , onEmptyApprovers : 'admin_rescue' as const } ,
4393+ record : { id : 'po1' , signoff_position : 'cfo' } ,
4394+ } ) ;
4395+ const refusalOf = ( p : Promise < unknown > ) => p . then ( ( ) => null , ( e : unknown ) => e ) ;
4396+
4397+ for ( const [ label , approver ] of [
4398+ [ 'a static position approver' , { type : 'position' , value : 'cfo' , organization : '$root' } ] ,
4399+ [ 'an expression approver resolving as position' , { type : 'expression' , value : 'trigger.signoff_position' , resolveAs : 'position' , organization : '$root' } ] ,
4400+ ] as const ) {
4401+ it ( `control — ${ label } reaches the group CFO through the filter` , async ( ) => {
4402+ const req = await svc . openNodeRequest ( node ( approver ) , PLANT_CTX ) as any ;
4403+ expect ( req . pending_approvers ) . toEqual ( [ 'u_cfo' ] ) ;
4404+ expect ( filterApproversWhoCanRead ) . toHaveBeenCalledTimes ( 1 ) ;
4405+ } ) ;
4406+
4407+ it ( `${ label } : the filter's failure refuses the node and names the filter's read` , async ( ) => {
4408+ vi . mocked ( filterApproversWhoCanRead ) . mockRejectedValueOnce ( new Error ( 'membership read filter down' ) ) ;
4409+ const refusal = await refusalOf ( svc . openNodeRequest ( node ( approver ) , PLANT_CTX ) ) ;
4410+
4411+ expect ( refusal ) . toBeInstanceOf ( ApproverDirectoryUnreadableError ) ;
4412+ expect ( refusal ) . toMatchObject ( { approverType : 'position' , approverValue : 'cfo' , retryable : true } ) ;
4413+ expect ( ( refusal as ApproverDirectoryUnreadableError ) . failedRead ) . toMatch ( FILTER_READ ) ;
4414+ expect ( engine . _tables [ 'sys_approval_request' ] ?? [ ] ) . toEqual ( [ ] ) ;
4415+ } ) ;
4416+ }
4417+ } ) ;
4418+
43444419// #3807 follow-up: `queue` was not the only way to end up with a slot nobody
43454420// can act on — every GRAPH approver type falls back to the same literal when
43464421// its lookup finds no one, and that fallback used to happen in total silence.
0 commit comments