Skip to content

Commit 4eeb469

Browse files
committed
wip: pins and staffed fixtures for the reassign target check
Claude-Session: https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp Co-authored-by: Claude <noreply@anthropic.com>
1 parent bf2ca34 commit 4eeb469

6 files changed

Lines changed: 329 additions & 1 deletion

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): reassigning an approval to an address that resolves to no one is refused, instead of stranding the request on a slate nobody can decide
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is which `to` the runtime reassign door accepts in its request body. -->
11+
12+
**BREAKING** for a caller of `POST /api/v1/approvals/requests/:id/reassign` (or `ApprovalService.reassign`) that names a `to` no one can take.
13+
14+
- **What is refused.** FROM: any non-empty `to` that was not already pending was written onto the slate and answered 200, so `{"to":"usr_does_not_exist"}` left the request waiting on nobody until an override actor moved it again. TO: `to` must resolve in the request's organization, or the call answers `400 VALIDATION_FAILED` naming the address, and the slate, the audit trail and the notifications are unchanged:
15+
- a user id or an email: a user account carries it (the email as the account stores it), and that user is not a member of other organizations only;
16+
- `position:<name>`: someone holds that position in the request's organization;
17+
- any other `type:value` address (`team:`, `department:`, `org_membership_level:`, a stored `role:`): never, because no approver acts under it.
18+
- **Who is affected.** Every caller of the door, the admin rescue of a request whose slate names no one included. A missing user and a user of another organization get the same refusal text, so the answer does not reveal who exists elsewhere.
19+
- **Unchanged.** A caller who may not move the slot is still refused `403 FORBIDDEN` first, before any directory read. An empty `to`, and a `to` that is already pending, are refused as before. The SLA escalation `reassign` action is not this door and is unchanged.
20+
21+
**What a caller does.** Reassign to the user id or email of a person in the request's organization, or to `position:<name>` for a position someone in it holds.

‎packages/plugins/plugin-approvals/src/actor-lookups-hold-ids.integration.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ const sysUser = {
7575
email: { name: 'email', label: 'Email', type: 'text' as const },
7676
},
7777
};
78+
/**
79+
* Just enough of `sys_user_position` for a `position:` reassign target to have
80+
* a holder: a reassign target must resolve to someone (#25485).
81+
*/
82+
const sysUserPosition = {
83+
name: 'sys_user_position',
84+
label: 'User Position',
85+
fields: {
86+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
87+
user_id: { name: 'user_id', label: 'User', type: 'text' as const },
88+
position: { name: 'position', label: 'Position', type: 'text' as const },
89+
},
90+
};
7891
const deal = {
7992
name: 'crm_deal',
8093
label: 'Deal',
@@ -153,14 +166,16 @@ describe('ADR-0118 D1 — every sys_user lookup approvals writes holds an id or
153166
}), true);
154167
await engine.init();
155168
objects = await packageObjects();
156-
for (const def of [sysUser, deal, ...objects]) {
169+
for (const def of [sysUser, sysUserPosition, deal, ...objects]) {
157170
engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test');
158171
}
159172
await engine.syncSchemas();
160173
// A real `single` install's Default Organization: the system writes below
161174
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
162175
await installDefaultOrganization(engine);
163176
for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any);
177+
// The `position:finance` reassign target below has a holder.
178+
await engine.insert('sys_user_position', { id: 'up_finance', user_id: 'u_boss', position: 'finance' }, { context: SYSTEM } as any);
164179

165180
svc = new ApprovalService({
166181
engine: engine as any,

‎packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ describe('#4839 evidence — the sanctioned admin path covers an unavailable app
252252
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
253253
bindApprovalLockHook(engine as any);
254254
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100, stage: 'new' }];
255+
// The substitute has an account: a reassign target must name one (#25485).
256+
engine._tables['sys_user'] = [{ id: 'u7' }];
255257
});
256258

257259
/** An ordinary member's edit of the approval's target record. */

‎packages/plugins/plugin-approvals/src/approval-override-audit.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ describe('approval override audit marker (#4466)', () => {
102102
engine: engine as any,
103103
clock: { now: () => new Date(baseTime + (n++) * 1000) },
104104
});
105+
// The reassign target has an account: a reassign target must name one (#25485).
106+
engine._tables['sys_user'] = [{ id: 'u7' }];
105107
});
106108

107109
/** A request whose slate is properly STAFFED — one real, designated user. */

‎packages/plugins/plugin-approvals/src/approval-service.test.ts‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,16 @@ const ADMIN_SESSIONS: Array<[string, any]> = [
190190
const 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+
193203
function 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(/VALIDATION_FAILED/);
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+
/^VALIDATION_FAILED: cannot reassign to 'nobody-at-all' — it names no user in this request's organization\./, ['u9'],
1619+
);
1620+
await expectRefusedUnchanged(
1621+
id, svc.reassign(id, { actorId: 'root', to: 'usr_does_not_exist', from: 'u9' }, PLATFORM_ADMIN_CTX),
1622+
/^VALIDATION_FAILED: cannot reassign to 'usr_does_not_exist' — /, ['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+
/^VALIDATION_FAILED: cannot reassign to 'usr_does_not_exist' — /, ['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+
/^VALIDATION_FAILED: cannot reassign to 'grace@example.com' — it names no user/, ['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+
/^VALIDATION_FAILED: cannot reassign to 'u_other' — it names no user in this request's organization\./, ['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+
/^VALIDATION_FAILED: cannot reassign to 'position:finance' — no one holds position 'finance' in this request's organization\./,
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(/^FORBIDDEN:/);
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

Comments
 (0)