@@ -27,6 +27,12 @@ function makeFakeEngine() {
2727 if ( ! ( v as any [ ] ) . some ( sub => matches ( row , sub ) ) ) return false ;
2828 continue ;
2929 }
30+ // The record lock intersects the caller's predicate with the locked ids
31+ // (#4778), so the fake has to compose branches the way a driver does.
32+ if ( k === '$and' ) {
33+ if ( ! ( v as any [ ] ) . every ( sub => matches ( row , sub ) ) ) return false ;
34+ continue ;
35+ }
3036 const rv = row [ k ] ;
3137 if ( v != null && typeof v === 'object' && '$in' in ( v as any ) ) {
3238 if ( ! ( v as any ) . $in . includes ( rv ) ) return false ;
@@ -47,12 +53,16 @@ function makeFakeEngine() {
4753
4854 /** Every `update` the service made, with the context it presented (#3783). */
4955 const writes : Array < { object : string ; data : any ; context : any } > = [ ] ;
56+ /** Every `find` anyone made — pins what a guard does NOT query (#4778). */
57+ const finds : Array < { object : string ; options : any } > = [ ] ;
5058
5159 return {
5260 _tables : tables ,
5361 _hooks : hooks ,
5462 _writes : writes ,
63+ _finds : finds ,
5564 async find ( object : string , options ?: any ) {
65+ finds . push ( { object, options } ) ;
5666 const rows = ensure ( object ) . filter ( r => matches ( r , options ?. filter ?? options ?. where ) ) ;
5767 if ( options ?. orderBy ?. [ 0 ] ) {
5868 // Canonical SortNode key only (spec/data/query.zod.ts): a sloppy
@@ -1914,6 +1924,215 @@ describe('record-lock hook (node era)', () => {
19141924 } ) ;
19151925} ) ;
19161926
1927+ // ── #4778: the lock has to survive a PREDICATE (multi) update ────────────
1928+ //
1929+ // `engine.update()` extracts `input.id` only from a SCALAR `where.id`; every
1930+ // other predicate is a multi-row write that routes to `updateMany` and reaches
1931+ // the hook with NO id. The hook used to open with `if (!id) return`, reading
1932+ // "no row was resolved" as "nothing to authorize" when the truth was "nothing
1933+ // was ever queried" (the #4757 / #4630 fail-open shape). Rewriting the very
1934+ // same edit as `multi: true` then walked past the lock with NO privilege at
1935+ // all — no admin, no isSystem, no `lockRecord: false`, no whitelisted field.
1936+ //
1937+ // Both halves are pinned here, because extending a guard to more rows fails
1938+ // the other way just as easily: the refusals AND every exemption, on both
1939+ // predicate shapes.
1940+ describe ( 'record-lock hook — predicate (multi) updates (#4778)' , ( ) => {
1941+ let engine : ReturnType < typeof makeFakeEngine > ;
1942+ let svc : ApprovalService ;
1943+ let n = 0 ;
1944+ const baseTime = new Date ( '2026-01-15T10:00:00Z' ) . getTime ( ) ;
1945+
1946+ /** The two shapes that carry no `input.id` and used to bypass the lock. */
1947+ const SHAPES : Array < [ string , any ] > = [
1948+ [ 'an id-operator predicate' , { id : { $in : [ 'opp1' ] } } ] ,
1949+ [ 'a non-id predicate' , { stage : 'new' } ] ,
1950+ ] ;
1951+
1952+ const USER = { isSystem : false , positions : [ ] , userId : 'u1' } ;
1953+
1954+ /** A `multi: true` update, i.e. the ctx the engine builds for `updateMany`. */
1955+ const predicateUpdate = (
1956+ where : any ,
1957+ data : Record < string , unknown > ,
1958+ rest : Record < string , unknown > = { } ,
1959+ ) =>
1960+ engine . fire ( 'beforeUpdate' , {
1961+ object : 'opportunity' ,
1962+ input : { data, options : { ...( where === undefined ? { } : { where } ) , multi : true } } ,
1963+ session : USER ,
1964+ ...rest ,
1965+ } ) ;
1966+
1967+ /** Re-open the pending request with a different node-config snapshot. */
1968+ const reopenWith = async ( configExtra : Record < string , any > ) => {
1969+ engine . _tables [ 'sys_approval_request' ] = [ ] ;
1970+ engine . _tables [ 'sys_approval_action' ] = [ ] ;
1971+ await svc . openNodeRequest (
1972+ openInput ( [ 'u9' ] , { } , { approvalStatusField : 'approval_status' , ...configExtra } ) ,
1973+ CTX ,
1974+ ) ;
1975+ } ;
1976+
1977+ beforeEach ( async ( ) => {
1978+ engine = makeFakeEngine ( ) ;
1979+ n = 0 ;
1980+ svc = new ApprovalService ( { engine : engine as any , clock : { now : ( ) => new Date ( baseTime + ( n ++ ) * 1000 ) } } ) ;
1981+ bindApprovalLockHook ( engine as any ) ;
1982+ await svc . openNodeRequest ( openInput ( [ 'u9' ] , { } , { approvalStatusField : 'approval_status' } ) , CTX ) ;
1983+ // `opp1` carries the pending request; `opp2` is an unlocked neighbour that
1984+ // the same predicates also match.
1985+ engine . _tables [ 'opportunity' ] = [
1986+ { id : 'opp1' , amount : 100 , stage : 'new' } ,
1987+ { id : 'opp2' , amount : 100 , stage : 'new' } ,
1988+ ] ;
1989+ } ) ;
1990+
1991+ // ── the hole itself ───────────────────────────────────────────────
1992+
1993+ it . each ( SHAPES ) ( 'blocks %s that reaches the locked record' , async ( _label , where ) => {
1994+ await expect ( predicateUpdate ( where , { amount : 999 } ) ) . rejects . toThrow ( / R E C O R D _ L O C K E D / ) ;
1995+ } ) ;
1996+
1997+ it ( 'blocks an unscoped whole-table update — no predicate at all' , async ( ) => {
1998+ // `updateMany` gets an AST of `{ object }`, i.e. every row, so every locked
1999+ // row of the object is in reach. "No predicate" is the widest write there
2000+ // is; it must not be the one that reads as "nothing to authorize".
2001+ await expect ( predicateUpdate ( undefined , { amount : 999 } ) ) . rejects . toThrow ( / R E C O R D _ L O C K E D / ) ;
2002+ } ) ;
2003+
2004+ it ( 'names the locked record and its object in the refusal' , async ( ) => {
2005+ await expect ( predicateUpdate ( { stage : 'new' } , { amount : 999 } ) )
2006+ . rejects . toThrow ( / r e c o r d ' o p p 1 ' o f ' o p p o r t u n i t y ' i s l o c k e d / ) ;
2007+ } ) ;
2008+
2009+ // ── and it must not over-block: a lock is a PER-ROW verdict ────────
2010+
2011+ it ( 'allows a predicate that reaches only unlocked rows' , async ( ) => {
2012+ await expect ( predicateUpdate ( { id : { $in : [ 'opp2' ] } } , { amount : 999 } ) ) . resolves . toBeUndefined ( ) ;
2013+ } ) ;
2014+
2015+ it ( 'allows a non-id predicate that matches no locked row' , async ( ) => {
2016+ // `opp1` is `stage: 'new'`, so this predicate misses it. Resolving the row
2017+ // set is what makes the difference between refusing this write and
2018+ // refusing every bulk update on an object that has any approval open.
2019+ await expect ( predicateUpdate ( { stage : 'closed' } , { amount : 999 } ) ) . resolves . toBeUndefined ( ) ;
2020+ } ) ;
2021+
2022+ it ( 'never scans an object that has no pending approval at all' , async ( ) => {
2023+ engine . _finds . length = 0 ;
2024+ await expect (
2025+ engine . fire ( 'beforeUpdate' , {
2026+ object : 'other_object' ,
2027+ input : { data : { amount : 999 } , options : { where : { stage : 'new' } , multi : true } } ,
2028+ session : USER ,
2029+ } ) ,
2030+ ) . resolves . toBeUndefined ( ) ;
2031+ // One bookkeeping probe, and nothing else: the bound is on locked records,
2032+ // so a mass update of unlocked rows costs a single query.
2033+ expect ( engine . _finds . map ( f => f . object ) ) . toEqual ( [ 'sys_approval_request' ] ) ;
2034+ } ) ;
2035+
2036+ // ── fail closed when the row set cannot be decided ─────────────────
2037+
2038+ it ( 'fails closed past the 1000-record bound' , async ( ) => {
2039+ for ( let i = 0 ; i < 1001 ; i ++ ) {
2040+ engine . _tables [ 'sys_approval_request' ] . push ( {
2041+ id : `extra_${ i } ` ,
2042+ object_name : 'opportunity' ,
2043+ record_id : `bulk_${ i } ` ,
2044+ status : 'pending' ,
2045+ node_config_json : JSON . stringify ( { lockRecord : true } ) ,
2046+ } ) ;
2047+ }
2048+ await expect ( predicateUpdate ( { stage : 'new' } , { amount : 999 } ) )
2049+ . rejects . toThrow ( / R E C O R D _ L O C K E D .* m o r e t h a n 1 0 0 0 / s) ;
2050+ } ) ;
2051+
2052+ it ( 'fails closed when the match set cannot be resolved' , async ( ) => {
2053+ const realFind = engine . find . bind ( engine ) ;
2054+ engine . find = ( async ( object : string , options ?: any ) => {
2055+ if ( object === 'opportunity' ) throw new Error ( 'driver unavailable' ) ;
2056+ return realFind ( object , options ) ;
2057+ } ) as typeof engine . find ;
2058+ await expect ( predicateUpdate ( { stage : 'new' } , { amount : 999 } ) )
2059+ . rejects . toThrow ( / R E C O R D _ L O C K E D .* c a n n o t d e t e r m i n e w h i c h r o w s / s) ;
2060+ } ) ;
2061+
2062+ // ── every exemption moves with the guard (the other failure mode) ──
2063+
2064+ it . each ( SHAPES ) ( 'allows engine self-writes (system session) via %s' , async ( _label , where ) => {
2065+ await expect (
2066+ predicateUpdate ( where , { amount : 999 } , { session : { isSystem : true , positions : [ ] } } ) ,
2067+ ) . resolves . toBeUndefined ( ) ;
2068+ } ) ;
2069+
2070+ it . each ( SHAPES ) ( 'allows an admin override via %s' , async ( _label , where ) => {
2071+ await expect (
2072+ predicateUpdate ( where , { amount : 999 } , { session : { isSystem : false , roles : [ 'admin' ] } } ) ,
2073+ ) . resolves . toBeUndefined ( ) ;
2074+ } ) ;
2075+
2076+ it . each ( SHAPES ) ( 'allows a status-mirror write via %s' , async ( _label , where ) => {
2077+ await expect ( predicateUpdate ( where , { approval_status : 'approved' } ) ) . resolves . toBeUndefined ( ) ;
2078+ } ) ;
2079+
2080+ it . each ( SHAPES ) ( 'allows the OWNING run to write its own target record via %s' , async ( _label , where ) => {
2081+ await expect (
2082+ predicateUpdate ( where , { amount : 999 } , { provenance : { flowRunId : 'run_1' } } ) ,
2083+ ) . resolves . toBeUndefined ( ) ;
2084+ } ) ;
2085+
2086+ it . each ( SHAPES ) ( 'allows the write when the node opted out of the lock, via %s' , async ( _label , where ) => {
2087+ await reopenWith ( { lockRecord : false } ) ;
2088+ await expect ( predicateUpdate ( where , { amount : 999 } ) ) . resolves . toBeUndefined ( ) ;
2089+ } ) ;
2090+
2091+ // ── …and the exemptions stay as narrow as on the by-id path ────────
2092+
2093+ it ( 'still blocks a DIFFERENT run on the predicate path' , async ( ) => {
2094+ await expect (
2095+ predicateUpdate ( { stage : 'new' } , { amount : 999 } , { provenance : { flowRunId : 'run_other' } } ) ,
2096+ ) . rejects . toThrow ( / R E C O R D _ L O C K E D / ) ;
2097+ } ) ;
2098+
2099+ it ( 'still blocks a mirror write that changes anything else too' , async ( ) => {
2100+ await expect (
2101+ predicateUpdate ( { stage : 'new' } , { approval_status : 'approved' , amount : 999 } ) ,
2102+ ) . rejects . toThrow ( / R E C O R D _ L O C K E D / ) ;
2103+ } ) ;
2104+
2105+ it ( 'still blocks an identity-less caller with no provenance at all' , async ( ) => {
2106+ await expect (
2107+ engine . fire ( 'beforeUpdate' , {
2108+ object : 'opportunity' ,
2109+ input : { data : { amount : 999 } , options : { where : { stage : 'new' } , multi : true } } ,
2110+ } ) ,
2111+ ) . rejects . toThrow ( / R E C O R D _ L O C K E D / ) ;
2112+ } ) ;
2113+
2114+ it ( 'judges a multi-row write by EACH request it reaches' , async ( ) => {
2115+ // Two records, two independent approvals: one opted out of the lock, one
2116+ // did not. A predicate spanning both is refused by the one that locks.
2117+ engine . _tables [ 'sys_approval_request' ] . push ( {
2118+ id : 'req_2' ,
2119+ object_name : 'opportunity' ,
2120+ record_id : 'opp2' ,
2121+ status : 'pending' ,
2122+ flow_run_id : 'run_2' ,
2123+ node_config_json : JSON . stringify ( { lockRecord : false } ) ,
2124+ } ) ;
2125+ await expect ( predicateUpdate ( { id : { $in : [ 'opp2' ] } } , { amount : 999 } ) ) . resolves . toBeUndefined ( ) ;
2126+ await expect ( predicateUpdate ( { id : { $in : [ 'opp1' , 'opp2' ] } } , { amount : 999 } ) )
2127+ . rejects . toThrow ( / r e c o r d ' o p p 1 ' / ) ;
2128+ } ) ;
2129+
2130+ it ( 'ignores a request that is no longer pending' , async ( ) => {
2131+ engine . _tables [ 'sys_approval_request' ] [ 0 ] . status = 'approved' ;
2132+ await expect ( predicateUpdate ( { stage : 'new' } , { amount : 999 } ) ) . resolves . toBeUndefined ( ) ;
2133+ } ) ;
2134+ } ) ;
2135+
19172136// ── #3456 recovery half: release records held by a dead approval run ──
19182137//
19192138// The prevention half above stops a run from dying on its own lock. This sweep
0 commit comments