@@ -190,6 +190,16 @@ const ADMIN_SESSIONS: Array<[string, any]> = [
190190const asUser = ( userId : string ) =>
191191 ( { userId, tenantId : 't1' , positions : [ ] , permissions : [ ] } ) as any ;
192192
193+ /**
194+ * Give each of `ids` a `sys_user` row. A reassign target must name an account
195+ * (#25485), so a test that hands a slot to a user id staffs the directory with
196+ * it first; the doubles start with an empty `sys_user`.
197+ */
198+ function staffDirectory ( engine : { _tables : Record < string , FakeRow [ ] > } , ...ids : string [ ] ) : void {
199+ const users = ( engine . _tables [ 'sys_user' ] ??= [ ] ) ;
200+ for ( const id of ids ) if ( ! users . some ( u => u . id === id ) ) users . push ( { id } ) ;
201+ }
202+
193203function nodeConfig ( approvers : string [ ] , extra : Record < string , any > = { } ) {
194204 return {
195205 approvers : approvers . map ( v => ( { type : 'user' as const , value : v } ) ) ,
@@ -1407,6 +1417,7 @@ describe('ApprovalService (node era)', () => {
14071417
14081418 it ( 'reassign: names the record' , async ( ) => {
14091419 const id = await openRequestId ( openInput ( [ 'u9' ] ) , CTX ) ;
1420+ staffDirectory ( engine , 'u7' ) ;
14101421 const out = await svc . reassign ( id , { actorId : 'u9' , to : 'u7' } , asUser ( 'u9' ) ) ;
14111422 expect ( named ( 'approval_reassign' , out ) ) . toBe ( 'Reassigned "Acme Renewal"' ) ;
14121423 } ) ;
@@ -1509,6 +1520,7 @@ describe('ApprovalService (node era)', () => {
15091520
15101521 it ( 'reassign: hands the slot to a new approver and audits the move on structured fields (#4365)' , async ( ) => {
15111522 const req = await svc . openNodeRequest ( openInput ( [ 'u9' , 'u2' ] ) , CTX ) ;
1523+ staffDirectory ( engine , 'u7' ) ;
15121524 const out = await svc . reassign ( req . id , { actorId : 'u9' , to : 'u7' } , asUser ( 'u9' ) ) ;
15131525 expect ( out . request . pending_approvers ) . toEqual ( [ 'u7' , 'u2' ] ) ;
15141526 const actions = await svc . listActions ( req . id , SYS ) ;
@@ -1521,6 +1533,7 @@ describe('ApprovalService (node era)', () => {
15211533
15221534 it ( 'reassign: a user comment is stored verbatim alongside the structured fields (#4365)' , async ( ) => {
15231535 const req = await svc . openNodeRequest ( openInput ( [ 'u9' ] ) , CTX ) ;
1536+ staffDirectory ( engine , 'u7' ) ;
15241537 await svc . reassign ( req . id , { actorId : 'u9' , to : 'u7' , comment : 'On leave next week' } , asUser ( 'u9' ) ) ;
15251538 const actions = await svc . listActions ( req . id , SYS ) ;
15261539 expect ( actions . at ( - 1 ) ) . toMatchObject ( {
@@ -1549,6 +1562,7 @@ describe('ApprovalService (node era)', () => {
15491562 // approval-requested-notification.integration.test.ts); this test reads
15501563 // only what the step under test sends after it.
15511564 expect ( emitted . splice ( 0 ) . map ( e => e . topic ) ) . toEqual ( [ 'approval.requested' ] ) ;
1565+ staffDirectory ( engine , 'u7' ) ;
15521566 await svc . reassign ( req . id , { actorId : 'u9' , to : 'u7' } , asUser ( 'u9' ) ) ;
15531567 expect ( emitted ) . toHaveLength ( 1 ) ;
15541568 expect ( emitted [ 0 ] ) . toMatchObject ( { topic : 'approval.reassigned' , audience : [ 'u7' ] } ) ;
@@ -1560,6 +1574,148 @@ describe('ApprovalService (node era)', () => {
15601574 await expect ( svc . reassign ( req . id , { actorId : 'u9' , to : 'u2' } , asUser ( 'u9' ) ) ) . rejects . toThrow ( / V A L I D A T I O N _ F A I L E D / ) ;
15611575 } ) ;
15621576
1577+ // ── reassign target resolution (#25485) ─────────────────────────
1578+ //
1579+ // A `to` that resolves to no one used to be written onto the slate, so the
1580+ // request waited on nobody until an override actor moved it again. Each
1581+ // refusal pins all three halves: the named VALIDATION_FAILED, the slate and
1582+ // its index unchanged, and no audit row or notice for a move that did not
1583+ // happen. Each preservation pin reads what the slate actually became.
1584+ describe ( 'reassign target resolution (#25485)' , ( ) => {
1585+ const PLATFORM_ADMIN_CTX = { userId : 'root' , tenantId : 't1' , positions : [ ] , permissions : [ 'admin_full_access' ] } as any ;
1586+ let emitted : any [ ] ;
1587+
1588+ beforeEach ( ( ) => {
1589+ emitted = [ ] ;
1590+ svc . attachMessaging ( { async emit ( input ) { emitted . push ( input ) ; } } ) ;
1591+ } ) ;
1592+
1593+ /** Open on `slate` and hand back the request id, with the opening's notices cleared. */
1594+ async function openOn ( slate : string [ ] ) : Promise < string > {
1595+ const req = await svc . openNodeRequest ( openInput ( slate ) , CTX ) ;
1596+ // The request takes the acting context's organization here (no schema on
1597+ // the double), and every screen below is judged against it.
1598+ expect ( engine . _tables [ 'sys_approval_request' ] . find ( r => r . id === req . id ) ?. organization_id ) . toBe ( 't1' ) ;
1599+ emitted . splice ( 0 ) ;
1600+ return req . id ;
1601+ }
1602+
1603+ async function expectRefusedUnchanged ( id : string , move : Promise < unknown > , message : RegExp , slate : string [ ] ) {
1604+ await expect ( move ) . rejects . toThrow ( message ) ;
1605+ const row = engine . _tables [ 'sys_approval_request' ] . find ( r => r . id === id ) ! ;
1606+ expect ( row . pending_approvers ) . toBe ( slate . join ( ',' ) ) ;
1607+ const indexed = ( engine . _tables [ 'sys_approval_approver' ] ?? [ ] ) . filter ( r => r . request_id === id ) ;
1608+ expect ( indexed . map ( r => r . approver ) . sort ( ) ) . toEqual ( [ ...slate ] . sort ( ) ) ;
1609+ const moves = ( engine . _tables [ 'sys_approval_action' ] ?? [ ] ) . filter ( r => r . request_id === id && r . action === 'reassign' ) ;
1610+ expect ( moves ) . toEqual ( [ ] ) ;
1611+ expect ( emitted . filter ( e => e . topic === 'approval.reassigned' ) ) . toEqual ( [ ] ) ;
1612+ }
1613+
1614+ it ( 'refuses a user id no account carries, as the slot holder and as a platform admin (the card\'s two measurements)' , async ( ) => {
1615+ const id = await openOn ( [ 'u9' ] ) ;
1616+ await expectRefusedUnchanged (
1617+ id , svc . reassign ( id , { actorId : 'u9' , to : 'nobody-at-all' } , asUser ( 'u9' ) ) ,
1618+ / ^ V A L I D A T I O N _ F A I L E D : c a n n o t r e a s s i g n t o ' n o b o d y - a t - a l l ' — i t n a m e s n o u s e r i n t h i s r e q u e s t ' s o r g a n i z a t i o n \. / , [ 'u9' ] ,
1619+ ) ;
1620+ await expectRefusedUnchanged (
1621+ id , svc . reassign ( id , { actorId : 'root' , to : 'usr_does_not_exist' , from : 'u9' } , PLATFORM_ADMIN_CTX ) ,
1622+ / ^ V A L I D A T I O N _ F A I L E D : c a n n o t r e a s s i g n t o ' u s r _ d o e s _ n o t _ e x i s t ' — / , [ 'u9' ] ,
1623+ ) ;
1624+ } ) ;
1625+
1626+ it ( 'refuses the admin rescue onto an unresolvable target too: the override does not exempt the new slot' , async ( ) => {
1627+ const id = await openOn ( [ 'position:ghost' ] ) ;
1628+ await expectRefusedUnchanged (
1629+ id , svc . reassign ( id , { actorId : 'root' , to : 'usr_does_not_exist' } , PLATFORM_ADMIN_CTX ) ,
1630+ / ^ V A L I D A T I O N _ F A I L E D : c a n n o t r e a s s i g n t o ' u s r _ d o e s _ n o t _ e x i s t ' — / , [ 'position:ghost' ] ,
1631+ ) ;
1632+ } ) ;
1633+
1634+ it ( 'refuses an email no account carries' , async ( ) => {
1635+ engine . _tables [ 'sys_user' ] = [ { id : 'u7' , email : 'ada@example.com' } ] ;
1636+ const id = await openOn ( [ 'u9' ] ) ;
1637+ await expectRefusedUnchanged (
1638+ id , svc . reassign ( id , { actorId : 'u9' , to : 'grace@example.com' } , asUser ( 'u9' ) ) ,
1639+ / ^ V A L I D A T I O N _ F A I L E D : c a n n o t r e a s s i g n t o ' g r a c e @ e x a m p l e .c o m ' — i t n a m e s n o u s e r / , [ 'u9' ] ,
1640+ ) ;
1641+ } ) ;
1642+
1643+ it ( 'refuses a user who is provably a member of other organizations only, in the same words as a missing user' , async ( ) => {
1644+ engine . _tables [ 'sys_user' ] = [ { id : 'u_other' } ] ;
1645+ engine . _tables [ 'sys_member' ] = [ { id : 'm1' , user_id : 'u_other' , organization_id : 't2' } ] ;
1646+ const id = await openOn ( [ 'u9' ] ) ;
1647+ const missing = await svc . reassign ( id , { actorId : 'u9' , to : 'u_missing' } , asUser ( 'u9' ) ) . catch ( ( e : Error ) => e . message ) ;
1648+ const elsewhere = await svc . reassign ( id , { actorId : 'u9' , to : 'u_other' } , asUser ( 'u9' ) ) . catch ( ( e : Error ) => e . message ) ;
1649+ // One text for both, so the answer is not a probe of other tenants' directories.
1650+ expect ( elsewhere ) . toBe ( String ( missing ) . replace ( 'u_missing' , 'u_other' ) ) ;
1651+ await expectRefusedUnchanged (
1652+ id , svc . reassign ( id , { actorId : 'u9' , to : 'u_other' } , asUser ( 'u9' ) ) ,
1653+ / ^ V A L I D A T I O N _ F A I L E D : c a n n o t r e a s s i g n t o ' u _ o t h e r ' — i t n a m e s n o u s e r i n t h i s r e q u e s t ' s o r g a n i z a t i o n \. / , [ 'u9' ] ,
1654+ ) ;
1655+ } ) ;
1656+
1657+ it ( 'refuses a position no one holds in the request\'s organization, a holder in another one included' , async ( ) => {
1658+ engine . _tables [ 'sys_user_position' ] = [ { id : 'up1' , user_id : 'u_far' , position : 'finance' , organization_id : 't2' } ] ;
1659+ const id = await openOn ( [ 'u9' ] ) ;
1660+ await expectRefusedUnchanged (
1661+ id , svc . reassign ( id , { actorId : 'u9' , to : 'position:finance' } , asUser ( 'u9' ) ) ,
1662+ / ^ V A L I D A T I O N _ F A I L E D : c a n n o t r e a s s i g n t o ' p o s i t i o n : f i n a n c e ' — n o o n e h o l d s p o s i t i o n ' f i n a n c e ' i n t h i s r e q u e s t ' s o r g a n i z a t i o n \. / ,
1663+ [ 'u9' ] ,
1664+ ) ;
1665+ } ) ;
1666+
1667+ it ( 'refuses every other type:value literal: no actor takes a team, department or membership-tier address' , async ( ) => {
1668+ engine . _tables [ 'sys_team' ] = [ { id : 'ops' , organization_id : 't1' } ] ;
1669+ const id = await openOn ( [ 'u9' ] ) ;
1670+ for ( const literal of [ 'team:ops' , 'department:sales' , 'org_membership_level:admin' , 'role:sales_manager' ] ) {
1671+ await expectRefusedUnchanged (
1672+ id , svc . reassign ( id , { actorId : 'u9' , to : literal } , asUser ( 'u9' ) ) ,
1673+ new RegExp ( `^VALIDATION_FAILED: cannot reassign to '${ literal } ' — it is a slot literal no approver acts under` ) ,
1674+ [ 'u9' ] ,
1675+ ) ;
1676+ }
1677+ } ) ;
1678+
1679+ it ( 'a caller who may not move the slot is refused FORBIDDEN before the target is resolved: no directory answer leaks' , async ( ) => {
1680+ const id = await openOn ( [ 'u9' ] ) ;
1681+ const reads = engine . _finds . length ;
1682+ await expect ( svc . reassign ( id , { actorId : 'intruder' , to : 'nobody-at-all' } , asUser ( 'intruder' ) ) )
1683+ . rejects . toThrow ( / ^ F O R B I D D E N : / ) ;
1684+ // The caller's OWN account is read (its email is an acting address);
1685+ // the target is never looked up.
1686+ expect ( engine . _finds . slice ( reads ) . filter ( f => JSON . stringify ( f . options ?. where ?? f . options ?. filter ?? { } )
1687+ . includes ( 'nobody-at-all' ) ) ) . toEqual ( [ ] ) ;
1688+ } ) ;
1689+
1690+ it ( 'moves the slot to a user id of a member here, and to one with no membership rows at all (no tenancy fact, as the sibling screens read it)' , async ( ) => {
1691+ engine . _tables [ 'sys_user' ] = [ { id : 'u7' } , { id : 'u8' } ] ;
1692+ engine . _tables [ 'sys_member' ] = [ { id : 'm1' , user_id : 'u7' , organization_id : 't1' } , { id : 'm2' , user_id : 'u7' , organization_id : 't2' } ] ;
1693+ const id = await openOn ( [ 'u9' , 'u2' ] ) ;
1694+ const first = await svc . reassign ( id , { actorId : 'u9' , to : 'u7' } , asUser ( 'u9' ) ) ;
1695+ expect ( first . request . pending_approvers ) . toEqual ( [ 'u7' , 'u2' ] ) ;
1696+ const second = await svc . reassign ( id , { actorId : 'u2' , to : 'u8' } , asUser ( 'u2' ) ) ;
1697+ expect ( second . request . pending_approvers ) . toEqual ( [ 'u7' , 'u8' ] ) ;
1698+ expect ( emitted . filter ( e => e . topic === 'approval.reassigned' ) . map ( e => e . audience ) ) . toEqual ( [ [ 'u7' ] , [ 'u8' ] ] ) ;
1699+ } ) ;
1700+
1701+ it ( 'moves the slot to an email an account carries, in the spelling the account stores' , async ( ) => {
1702+ engine . _tables [ 'sys_user' ] = [ { id : 'u7' , email : 'ada@example.com' } ] ;
1703+ const id = await openOn ( [ 'u9' ] ) ;
1704+ const out = await svc . reassign ( id , { actorId : 'u9' , to : 'ada@example.com' } , asUser ( 'u9' ) ) ;
1705+ expect ( out . request . pending_approvers ) . toEqual ( [ 'ada@example.com' ] ) ;
1706+ } ) ;
1707+
1708+ it ( 'moves the slot to a position someone holds here, and its holder then takes it' , async ( ) => {
1709+ engine . _tables [ 'sys_user_position' ] = [ { id : 'up1' , user_id : 'u_fin' , position : 'finance' , organization_id : 't1' } ] ;
1710+ const id = await openOn ( [ 'u9' ] ) ;
1711+ const out = await svc . reassign ( id , { actorId : 'u9' , to : 'position:finance' } , asUser ( 'u9' ) ) ;
1712+ expect ( out . request . pending_approvers ) . toEqual ( [ 'position:finance' ] ) ;
1713+ const decided = await svc . decideNode ( id , { decision : 'approve' } as any ,
1714+ { userId : 'u_fin' , tenantId : 't1' , positions : [ 'finance' ] , permissions : [ ] } as any ) ;
1715+ expect ( decided . request . status ) . toBe ( 'approved' ) ;
1716+ } ) ;
1717+ } ) ;
1718+
15631719 it ( 'remind: notifies pending approvers, audits, and throttles repeats' , async ( ) => {
15641720 const emitted : any [ ] = [ ] ;
15651721 svc . attachMessaging ( { async emit ( input ) { emitted . push ( input ) ; } } ) ;
@@ -1674,6 +1830,7 @@ describe('ApprovalService (node era)', () => {
16741830 expect ( await svc . redeemActionToken ( short . approve ) ) . toMatchObject ( { ok : false , reason : 'expired' } ) ;
16751831
16761832 const live = await svc . issueActionTokens ( req . id , 'u9' ) ;
1833+ staffDirectory ( engine , 'u7' ) ;
16771834 await svc . reassign ( req . id , { actorId : 'u9' , to : 'u7' } , asUser ( 'u9' ) ) ;
16781835 expect ( await svc . redeemActionToken ( live . approve ) ) . toMatchObject ( { ok : false , reason : 'not_approver' } ) ;
16791836
@@ -1788,6 +1945,7 @@ describe('ApprovalService (node era)', () => {
17881945 const req = await svc . openNodeRequest (
17891946 openInput ( [ 'u9' , 'u2' ] , { } , { escalation : { timeoutHours : 1 , action : 'reassign' , escalateTo : 'boss' , notifySubmitter : false } } ) , CTX ,
17901947 ) ;
1948+ staffDirectory ( engine , 'u7' ) ;
17911949 await svc . reassign ( req . id , { actorId : 'u9' , to : 'u7' } , SYS ) ;
17921950 expect ( indexRows ( ) . map ( r => r . approver ) . sort ( ) ) . toEqual ( [ 'u2' , 'u7' ] ) ;
17931951
@@ -2187,6 +2345,7 @@ describe('ApprovalService — admin override (#3424)', () => {
21872345
21882346 it ( 'an admin can reassign a stuck request to a real approver, who then decides normally' , async ( ) => {
21892347 const req = await svc . openNodeRequest ( stuckInput ( ) , CTX ) ;
2348+ staffDirectory ( engine , 'u7' ) ;
21902349 const out = await svc . reassign ( req . id , { actorId : 'owner' , to : 'u7' } , TENANT_ADMIN ) ;
21912350 expect ( out . request . pending_approvers ) . toEqual ( [ 'u7' ] ) ;
21922351 const decided = await svc . decideNode (
@@ -3414,6 +3573,7 @@ describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () =
34143573 const emitted : any [ ] = [ ] ;
34153574 svc . attachMessaging ( { async emit ( input ) { emitted . push ( input ) ; } } ) ;
34163575 const req = await svc . openNodeRequest ( openInput ( [ 'u1' , 'u2' ] ) , CTX ) ;
3576+ staffDirectory ( engine , 'u7' ) ;
34173577 await svc . reassign ( req . id , { actorId : 'u1' , to : 'u7' } , SYS ) ;
34183578 const note = emitted . find ( e => e . topic === 'approval.reassigned' ) ;
34193579 expect ( note . payload . actionUrl ) . toBe ( `/system/approvals?request=${ encodeURIComponent ( req . id ) } ` ) ;
@@ -4433,6 +4593,7 @@ describe('ApprovalService — every slot reader takes the acting addresses (#213
44334593 await expect ( svc . reassign ( req . id , { to : 'u_next' } as any , BYSTANDER ) )
44344594 . rejects . toThrow ( "FORBIDDEN: 'u_bystander' is not a pending approver on this request" ) ;
44354595 // With `from` unnamed, the slot handed over is the one the holder takes.
4596+ staffDirectory ( engine , 'u_next' ) ;
44364597 const moved = await svc . reassign ( req . id , { to : 'u_next' } as any , HOLDER ) ;
44374598 expect ( moved . request . pending_approvers ) . toEqual ( [ 'u_next' ] ) ;
44384599 const [ reassigned ] = actionsOf ( engine , req . id , 'reassign' ) ;
@@ -4654,6 +4815,7 @@ describe('ApprovalService — the `role:` arm retired; the override decides what
46544815 const svc = svcFor ( engine ) ;
46554816 const { id } = await plant ( engine , svc ) ;
46564817 const [ , admin ] = RESCUERS [ 2 ] ;
4818+ staffDirectory ( engine , 'u_holder' ) ;
46574819 const moved = await svc . reassign ( id , { actorId : admin . userId , to : 'u_holder' } as any , admin ) ;
46584820 expect ( moved . request . pending_approvers ) . toEqual ( [ 'u_holder' ] ) ;
46594821 const out = await svc . decideNode ( id , { decision : 'approve' } as any , HOLDER ) ;
0 commit comments