|
11 | 11 |
|
12 | 12 | import { describe, it, expect, beforeEach } from 'vitest'; |
13 | 13 | import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js'; |
14 | | -import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js'; |
| 14 | +import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js'; |
15 | 15 |
|
16 | 16 | interface FakeRow { [k: string]: any } |
17 | 17 |
|
@@ -1223,3 +1223,67 @@ describe('ApprovalService — out-of-office delegation (#1322)', () => { |
1223 | 1223 | expect(req.pending_approvers).toEqual(['bob']); |
1224 | 1224 | }); |
1225 | 1225 | }); |
| 1226 | + |
| 1227 | +// ── Delegation self-service write guard (#1322 follow-up) ───────────── |
| 1228 | +// |
| 1229 | +// sys_approval_delegation is apiEnabled CRUD; a member must not be able to |
| 1230 | +// forge a delegation for someone else (delegator_id = victim) and reroute the |
| 1231 | +// victim's approvals. The guard forces delegator_id == acting user for normal |
| 1232 | +// writes; system/admin contexts bypass. Row-ownership on update/delete is the |
| 1233 | +// platform's created_by RLS (not exercised here). |
| 1234 | +describe('sys_approval_delegation write guard (#1322)', () => { |
| 1235 | + const DEL = 'sys_approval_delegation'; |
| 1236 | + let engine: ReturnType<typeof makeFakeEngine>; |
| 1237 | + |
| 1238 | + beforeEach(() => { |
| 1239 | + engine = makeFakeEngine(); |
| 1240 | + bindDelegationWriteGuard(engine as any); |
| 1241 | + }); |
| 1242 | + |
| 1243 | + const fireInsert = (data: any, session: any) => |
| 1244 | + (engine as any).fire('beforeInsert', { object: DEL, input: { data }, session }); |
| 1245 | + const fireUpdate = (data: any, session: any) => |
| 1246 | + (engine as any).fire('beforeUpdate', { object: DEL, input: { id: data?.id ?? 'd1', data }, session }); |
| 1247 | + const member = (userId?: string) => ({ isSystem: false, roles: [], ...(userId ? { userId } : {}) }); |
| 1248 | + |
| 1249 | + it('allows a member to create their own delegation', async () => { |
| 1250 | + await expect(fireInsert({ delegator_id: 'u1', delegate_id: 'u2' }, member('u1'))).resolves.toBeUndefined(); |
| 1251 | + }); |
| 1252 | + |
| 1253 | + it('rejects a member forging a delegation for someone else', async () => { |
| 1254 | + await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, member('u1'))).rejects.toThrow(/FORBIDDEN/); |
| 1255 | + }); |
| 1256 | + |
| 1257 | + it('stamps the caller as delegator when omitted on insert', async () => { |
| 1258 | + const data: any = { delegate_id: 'u2' }; |
| 1259 | + await fireInsert(data, member('u1')); |
| 1260 | + expect(data.delegator_id).toBe('u1'); |
| 1261 | + }); |
| 1262 | + |
| 1263 | + it('rejects an unauthenticated non-system insert', async () => { |
| 1264 | + await expect(fireInsert({ delegate_id: 'u2' }, member())).rejects.toThrow(/FORBIDDEN/); |
| 1265 | + }); |
| 1266 | + |
| 1267 | + it('bypasses the guard for system context', async () => { |
| 1268 | + await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, { isSystem: true })).resolves.toBeUndefined(); |
| 1269 | + }); |
| 1270 | + |
| 1271 | + it('lets an admin set the delegator to anyone', async () => { |
| 1272 | + await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u2' }, { isSystem: false, roles: ['admin'], userId: 'admin1' })).resolves.toBeUndefined(); |
| 1273 | + }); |
| 1274 | + |
| 1275 | + it('rejects a member relabelling delegator on update', async () => { |
| 1276 | + await expect(fireUpdate({ id: 'd1', delegator_id: 'victim' }, member('u1'))).rejects.toThrow(/FORBIDDEN/); |
| 1277 | + }); |
| 1278 | + |
| 1279 | + it('allows a member update that does not touch delegator_id', async () => { |
| 1280 | + await expect(fireUpdate({ id: 'd1', valid_until: '2026-06-01T00:00:00Z' }, member('u1'))).resolves.toBeUndefined(); |
| 1281 | + }); |
| 1282 | + |
| 1283 | + it('rejects a batch insert if any row names a foreign delegator', async () => { |
| 1284 | + await expect(fireInsert( |
| 1285 | + [{ delegator_id: 'u1', delegate_id: 'u2' }, { delegator_id: 'victim', delegate_id: 'u3' }], |
| 1286 | + member('u1'), |
| 1287 | + )).rejects.toThrow(/FORBIDDEN/); |
| 1288 | + }); |
| 1289 | +}); |
0 commit comments