@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +1023 -0
- package/dist/index.d.mts +650 -535
- package/dist/index.d.ts +650 -535
- package/dist/index.js +1713 -207
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1711 -197
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/scripts/i18n-extract.config.ts +6 -1
- package/src/approval-actor-impersonation.test.ts +330 -0
- package/src/approval-node.test.ts +160 -0
- package/src/approval-node.ts +57 -0
- package/src/approval-revise.test.ts +41 -34
- package/src/approval-service.test.ts +1408 -40
- package/src/approval-service.ts +1364 -107
- package/src/approvals-plugin.ts +36 -5
- package/src/approver-cross-org.integration.test.ts +206 -0
- package/src/approver-org-scope.test.ts +201 -0
- package/src/approver-org-scope.ts +261 -0
- package/src/index.ts +3 -0
- package/src/lifecycle-hooks.ts +22 -0
- package/src/record-lock-schedule-run.integration.test.ts +206 -0
- package/src/status-mirror-cascade.integration.test.ts +224 -0
- package/src/sys-approval-action.object.ts +9 -0
- package/src/sys-approval-delegation.object.test.ts +42 -0
- package/src/sys-approval-delegation.object.ts +3 -3
- package/src/sys-approval-request.object.test.ts +13 -0
- package/src/sys-approval-request.object.ts +17 -5
- package/src/translations/bundle-ownership.test.ts +48 -0
- package/src/translations/en.objects.generated.ts +111 -5
- package/src/translations/es-ES.objects.generated.ts +111 -5
- package/src/translations/ja-JP.objects.generated.ts +111 -5
- package/src/translations/zh-CN.objects.generated.ts +110 -4
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* the read API, and the global record-lock hook.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
12
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
13
13
|
import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
|
|
14
14
|
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
|
|
15
15
|
|
|
@@ -45,9 +45,13 @@ function makeFakeEngine() {
|
|
|
45
45
|
return true;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Every `update` the service made, with the context it presented (#3783). */
|
|
49
|
+
const writes: Array<{ object: string; data: any; context: any }> = [];
|
|
50
|
+
|
|
48
51
|
return {
|
|
49
52
|
_tables: tables,
|
|
50
53
|
_hooks: hooks,
|
|
54
|
+
_writes: writes,
|
|
51
55
|
async find(object: string, options?: any) {
|
|
52
56
|
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
|
|
53
57
|
if (options?.orderBy?.[0]) {
|
|
@@ -73,6 +77,7 @@ function makeFakeEngine() {
|
|
|
73
77
|
async update(object: string, idOrData: any, _opts?: any) {
|
|
74
78
|
const data = typeof idOrData === 'object' ? idOrData : _opts;
|
|
75
79
|
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
|
|
80
|
+
writes.push({ object, data, context: _opts?.context });
|
|
76
81
|
const table = ensure(object);
|
|
77
82
|
const i = table.findIndex(r => r.id === id);
|
|
78
83
|
if (i >= 0) table[i] = { ...table[i], ...data };
|
|
@@ -113,6 +118,15 @@ function makeFakeEngine() {
|
|
|
113
118
|
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
114
119
|
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
|
|
115
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The signed-in caller, when it is someone other than {@link CTX}'s `u1`.
|
|
123
|
+
* An approval action is recorded against the AUTHENTICATED caller (#3800), so a
|
|
124
|
+
* test that acts as `u9` has to present `u9`'s context — naming them in
|
|
125
|
+
* `actorId` while calling as `u1` is the impersonation the service now refuses.
|
|
126
|
+
*/
|
|
127
|
+
const asUser = (userId: string) =>
|
|
128
|
+
({ userId, tenantId: 't1', positions: [], permissions: [] }) as any;
|
|
129
|
+
|
|
116
130
|
function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
|
|
117
131
|
return {
|
|
118
132
|
approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
|
|
@@ -169,6 +183,40 @@ describe('ApprovalService (node era)', () => {
|
|
|
169
183
|
expect(JSON.parse(raw.node_config_json)).toMatchObject({ behavior: 'first_response', lockRecord: true });
|
|
170
184
|
});
|
|
171
185
|
|
|
186
|
+
// ── record-lock policy on the read row (objectui#2902) ──────────
|
|
187
|
+
//
|
|
188
|
+
// The lock is enforced in `lifecycle-hooks.ts` off `node_config_json`, but
|
|
189
|
+
// the row projection used to drop the flag entirely — so a client could see
|
|
190
|
+
// "a pending request exists" and nothing more, and had to assume every
|
|
191
|
+
// pending node locked the record. Chaining nodes with different policies
|
|
192
|
+
// made that visibly wrong. These pin the flag onto every read path.
|
|
193
|
+
|
|
194
|
+
it('lock_record: true when the node locks (the schema default)', async () => {
|
|
195
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
196
|
+
expect(req.lock_record).toBe(true);
|
|
197
|
+
const [listed] = await svc.listRequests({ object: 'opportunity', recordId: 'opp1' }, SYS);
|
|
198
|
+
expect(listed.lock_record).toBe(true);
|
|
199
|
+
expect((await svc.getRequest(req.id, SYS))!.lock_record).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('lock_record: false when the node opts out — the same read the hook honors', async () => {
|
|
203
|
+
const req = await svc.openNodeRequest(openInput(['u9'], {}, { lockRecord: false }), CTX);
|
|
204
|
+
expect(req.lock_record).toBe(false);
|
|
205
|
+
const [listed] = await svc.listRequests({ object: 'opportunity', recordId: 'opp1' }, SYS);
|
|
206
|
+
expect(listed.lock_record).toBe(false);
|
|
207
|
+
expect((await svc.getRequest(req.id, SYS))!.lock_record).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('lock_record: an unset lockRecord reads as locked, matching the hook default', async () => {
|
|
211
|
+
// The hook allows the write only on an explicit `=== false`; the flag must
|
|
212
|
+
// default the same way or the UI would offer an edit the server rejects.
|
|
213
|
+
const req = await svc.openNodeRequest(
|
|
214
|
+
{ ...openInput(['u9']), config: { approvers: [{ type: 'user' as const, value: 'u9' }], behavior: 'first_response' as const } } as any,
|
|
215
|
+
CTX,
|
|
216
|
+
);
|
|
217
|
+
expect(req.lock_record).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
172
220
|
it('openNodeRequest: deduplicates a pending request per (object, record)', async () => {
|
|
173
221
|
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
174
222
|
await expect(svc.openNodeRequest(openInput(['u9'], { runId: 'run_2' }), CTX))
|
|
@@ -187,6 +235,325 @@ describe('ApprovalService (node era)', () => {
|
|
|
187
235
|
expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
|
|
188
236
|
});
|
|
189
237
|
|
|
238
|
+
// ── approver expansion: field (live re-read + fan-out, #3447) ────
|
|
239
|
+
//
|
|
240
|
+
// A `field` approver names WHO decides from a record field. It must bind to
|
|
241
|
+
// the record's LIVE value at node entry — an earlier step (or the approver of
|
|
242
|
+
// an earlier step) may have written it after submit — not the trigger snapshot
|
|
243
|
+
// the run froze into `$record`. A multi-select user field fans out into one
|
|
244
|
+
// slot per user.
|
|
245
|
+
|
|
246
|
+
const fieldInput = (extra: Record<string, any> = {}) => ({
|
|
247
|
+
...openInput([]),
|
|
248
|
+
config: {
|
|
249
|
+
approvers: [{ type: 'field' as const, value: 'approvers_dynamic' }],
|
|
250
|
+
behavior: 'unanimous' as const,
|
|
251
|
+
lockRecord: true,
|
|
252
|
+
},
|
|
253
|
+
...extra,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('field approver: resolves against the LIVE record, not the trigger snapshot (#3447)', async () => {
|
|
257
|
+
// The minimal repro: submitted with the routing field empty; a prior step
|
|
258
|
+
// wrote the co-reviewers mid-flow. Resolution must see them.
|
|
259
|
+
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100, approvers_dynamic: ['u2', 'u3'] }];
|
|
260
|
+
const req = await svc.openNodeRequest(
|
|
261
|
+
fieldInput({ record: { id: 'opp1', amount: 100, approvers_dynamic: [] } }), CTX,
|
|
262
|
+
);
|
|
263
|
+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3']);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('field approver: the live value WINS over a stale snapshot value (#3447)', async () => {
|
|
267
|
+
// Snapshot named u1; the record now names u2. u2 decides.
|
|
268
|
+
engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2'] }];
|
|
269
|
+
const req = await svc.openNodeRequest(
|
|
270
|
+
fieldInput({ record: { id: 'opp1', approvers_dynamic: ['u1'] } }), CTX,
|
|
271
|
+
);
|
|
272
|
+
expect(req.pending_approvers).toEqual(['u2']);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('field approver: fans a multi-select user field into one slot per user (#3447)', async () => {
|
|
276
|
+
engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2', 'u3', 'u4'] }];
|
|
277
|
+
const req = await svc.openNodeRequest(fieldInput({ record: { id: 'opp1' } }), CTX);
|
|
278
|
+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3', 'u4']);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('field approver: fans a legacy CSV string field into multiple slots (#3447)', async () => {
|
|
282
|
+
engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: 'u2,u3' }];
|
|
283
|
+
const req = await svc.openNodeRequest(fieldInput({ record: { id: 'opp1' } }), CTX);
|
|
284
|
+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3']);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('field approver: falls back to the trigger snapshot when the record is gone (#3447)', async () => {
|
|
288
|
+
// No `opportunity` row — hard-deleted between submit and node entry. The
|
|
289
|
+
// snapshot still carries a value, so the request opens against it (warn but
|
|
290
|
+
// proceed) rather than wedging the flow.
|
|
291
|
+
const req = await svc.openNodeRequest(
|
|
292
|
+
fieldInput({ record: { id: 'opp1', approvers_dynamic: ['u7'] } }), CTX,
|
|
293
|
+
);
|
|
294
|
+
expect(req.pending_approvers).toEqual(['u7']);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ── approver expansion: expression (#3447 P2) ───────────────────
|
|
298
|
+
//
|
|
299
|
+
// A CEL expression resolved at node entry over three EXPLICIT roots:
|
|
300
|
+
// `current.*` (live record), `trigger.*` (submit snapshot), `vars.*` (flow
|
|
301
|
+
// variables). `record` / bare fields are rejected before evaluation — the
|
|
302
|
+
// runtime env would resolve them as dyn → null → a silently-empty slate.
|
|
303
|
+
|
|
304
|
+
const exprInput = (
|
|
305
|
+
value: string,
|
|
306
|
+
extra: Record<string, any> = {},
|
|
307
|
+
approverExtra: Record<string, any> = {},
|
|
308
|
+
configExtra: Record<string, any> = {},
|
|
309
|
+
) => ({
|
|
310
|
+
...openInput([]),
|
|
311
|
+
config: {
|
|
312
|
+
approvers: [{ type: 'expression' as const, value, ...approverExtra }],
|
|
313
|
+
behavior: 'unanimous' as const,
|
|
314
|
+
lockRecord: true,
|
|
315
|
+
...configExtra,
|
|
316
|
+
},
|
|
317
|
+
...extra,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('expression: current.* reads the LIVE record at node entry (#3447 P2)', async () => {
|
|
321
|
+
engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2', 'u3'] }];
|
|
322
|
+
const req = await svc.openNodeRequest(
|
|
323
|
+
exprInput('current.approvers_dynamic', { record: { id: 'opp1', approvers_dynamic: [] } }), CTX,
|
|
324
|
+
) as any;
|
|
325
|
+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3']);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('expression: trigger.* reads the submit-time snapshot, not the live row (#3447 P2)', async () => {
|
|
329
|
+
engine._tables['opportunity'] = [{ id: 'opp1', reviewer: 'new_reviewer' }];
|
|
330
|
+
const req = await svc.openNodeRequest(
|
|
331
|
+
exprInput('trigger.reviewer', { record: { id: 'opp1', reviewer: 'old_reviewer' } }), CTX,
|
|
332
|
+
) as any;
|
|
333
|
+
expect(req.pending_approvers).toEqual(['old_reviewer']);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('expression: vars.* reads flow variables; a CSV string fans out (#3447 P2)', async () => {
|
|
337
|
+
const req = await svc.openNodeRequest(
|
|
338
|
+
exprInput('vars.approval_lead.next_reviewers', {
|
|
339
|
+
variables: { approval_lead: { next_reviewers: 'u5, u6' } },
|
|
340
|
+
}), CTX,
|
|
341
|
+
) as any;
|
|
342
|
+
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('expression: an array result fans out into one slot per id (#3447 P2)', async () => {
|
|
346
|
+
const req = await svc.openNodeRequest(
|
|
347
|
+
exprInput('vars.picked', { variables: { picked: ['u2', 'u3', 'u4'] } }), CTX,
|
|
348
|
+
) as any;
|
|
349
|
+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3', 'u4']);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('expression: OOO delegation applies per resolved user (#3447 P2 / #1322)', async () => {
|
|
353
|
+
engine._tables['sys_approval_delegation'] = [{
|
|
354
|
+
id: 'del1', delegator_id: 'u2', delegate_id: 'u9',
|
|
355
|
+
valid_from: '2026-01-01T00:00:00Z', valid_until: '2026-12-31T00:00:00Z',
|
|
356
|
+
reason: 'leave', organization_id: 't1',
|
|
357
|
+
}];
|
|
358
|
+
const req = await svc.openNodeRequest(
|
|
359
|
+
exprInput('vars.picked', { variables: { picked: ['u2', 'u3'] } }), CTX,
|
|
360
|
+
) as any;
|
|
361
|
+
expect(req.pending_approvers.sort()).toEqual(['u3', 'u9']);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('expression: rejects a `record` root BEFORE evaluation, prescribing current/trigger (#3447 P2)', async () => {
|
|
365
|
+
await expect(svc.openNodeRequest(exprInput('record.approvers_dynamic'), CTX))
|
|
366
|
+
.rejects.toThrow(/VALIDATION_FAILED[\s\S]*`record`[\s\S]*current\.<field>/);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('expression: rejects an unknown bare root with the closed-root hint (#3447 P2)', async () => {
|
|
370
|
+
await expect(svc.openNodeRequest(exprInput('approvers_dynamic'), CTX))
|
|
371
|
+
.rejects.toThrow(/VALIDATION_FAILED.*approvers_dynamic.*current\.\*/s);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('expression: a non-parsing source fails loudly, not as an empty slate (#3447 P2)', async () => {
|
|
375
|
+
await expect(svc.openNodeRequest(exprInput('current..'), CTX))
|
|
376
|
+
.rejects.toThrow(/VALIDATION_FAILED.*does not parse/s);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('expression: a non-id result type (bool) fails loudly (#3447 P2)', async () => {
|
|
380
|
+
const req = svc.openNodeRequest(
|
|
381
|
+
exprInput('current.amount > 100', { record: { id: 'opp1', amount: 500 } }), CTX,
|
|
382
|
+
);
|
|
383
|
+
await expect(req).rejects.toThrow(/EXPRESSION_FAILED.*must yield ids/s);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('expression + resolveAs department: expands each returned id through the graph, one per_group group per department (#3447 P2)', async () => {
|
|
387
|
+
engine._tables['sys_business_unit'] = [
|
|
388
|
+
{ id: 'd1', active: true, organization_id: 't1' },
|
|
389
|
+
{ id: 'd2', active: true, organization_id: 't1' },
|
|
390
|
+
];
|
|
391
|
+
engine._tables['sys_business_unit_member'] = [
|
|
392
|
+
{ id: 'm1', business_unit_id: 'd1', user_id: 'u2' },
|
|
393
|
+
{ id: 'm2', business_unit_id: 'd1', user_id: 'u3' },
|
|
394
|
+
{ id: 'm3', business_unit_id: 'd2', user_id: 'u4' },
|
|
395
|
+
];
|
|
396
|
+
const req = await svc.openNodeRequest(
|
|
397
|
+
exprInput('vars.picked_departments', {
|
|
398
|
+
variables: { picked_departments: ['d1', 'd2'] },
|
|
399
|
+
}, { resolveAs: 'department' }, { behavior: 'per_group' }), CTX,
|
|
400
|
+
) as any;
|
|
401
|
+
expect(req.pending_approvers.sort()).toEqual(['u2', 'u3', 'u4']);
|
|
402
|
+
// Each department forms its own sub-group: one sign-off per department.
|
|
403
|
+
const raw = engine._tables['sys_approval_request'][0];
|
|
404
|
+
const snapshot = JSON.parse(raw.node_config_json);
|
|
405
|
+
expect(snapshot.__approverGroups).toEqual({
|
|
406
|
+
u2: ['#0:d1'], u3: ['#0:d1'], u4: ['#0:d2'],
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('expression + resolveAs: an unstaffed department keeps a literal slot (#3447 P2)', async () => {
|
|
411
|
+
engine._tables['sys_business_unit'] = [{ id: 'd9', active: true, organization_id: 't1' }];
|
|
412
|
+
const req = await svc.openNodeRequest(
|
|
413
|
+
exprInput('vars.picked', { variables: { picked: ['d9'] } }, { resolveAs: 'department' }), CTX,
|
|
414
|
+
) as any;
|
|
415
|
+
expect(req.pending_approvers).toEqual(['department:d9']);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('expression: __resolvedFrom snapshots the resolution INPUT for audit (#3447 P2)', async () => {
|
|
419
|
+
engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2'] }];
|
|
420
|
+
await svc.openNodeRequest(exprInput('current.approvers_dynamic', { record: { id: 'opp1' } }), CTX);
|
|
421
|
+
const raw = engine._tables['sys_approval_request'][0];
|
|
422
|
+
const snapshot = JSON.parse(raw.node_config_json);
|
|
423
|
+
expect(snapshot.__resolvedFrom).toEqual({ 'expression#0': ['u2'] });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('expression: a MISSING key is a loud error, never a silent empty slate (#3447 P2)', async () => {
|
|
427
|
+
// CEL map access on an absent key throws ("No such key") — deliberate:
|
|
428
|
+
// referencing a variable nobody wrote is a wiring bug, not "no approvers".
|
|
429
|
+
// An EMPTY slate is expressed by a present-but-empty value (next test
|
|
430
|
+
// group); authors guard optional inputs with `has(...)` / `.?` explicitly.
|
|
431
|
+
await expect(svc.openNodeRequest(
|
|
432
|
+
exprInput('vars.never_written', { variables: {} }), CTX,
|
|
433
|
+
)).rejects.toThrow(/EXPRESSION_FAILED.*No such key/s);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ── onEmptyApprovers policy (#3447 P2) ──────────────────────────
|
|
437
|
+
//
|
|
438
|
+
// "Empty" = the expression/field RESOLVED (key present) but yielded nobody.
|
|
439
|
+
// A missing key is a loud error instead (test above).
|
|
440
|
+
|
|
441
|
+
it("onEmptyApprovers 'fail': an empty slate fails the open loudly", async () => {
|
|
442
|
+
await expect(svc.openNodeRequest(
|
|
443
|
+
exprInput('vars.picked', { variables: { picked: [] } }, {}, { onEmptyApprovers: 'fail' }), CTX,
|
|
444
|
+
)).rejects.toThrow(/NO_APPROVERS/);
|
|
445
|
+
expect(engine._tables['sys_approval_request'] ?? []).toHaveLength(0);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("onEmptyApprovers 'auto_approve': no request opens, outcome says autoApproved", async () => {
|
|
449
|
+
const outcome = await svc.openNodeRequest(
|
|
450
|
+
exprInput('vars.picked', { variables: { picked: [] } }, {}, { onEmptyApprovers: 'auto_approve' }), CTX,
|
|
451
|
+
);
|
|
452
|
+
expect(outcome).toEqual({ autoApproved: true, reason: 'empty_approvers' });
|
|
453
|
+
expect(engine._tables['sys_approval_request'] ?? []).toHaveLength(0);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("onEmptyApprovers default ('admin_rescue'): the request still opens for admin takeover (#3424)", async () => {
|
|
457
|
+
const req = await svc.openNodeRequest(
|
|
458
|
+
exprInput('vars.picked', { variables: { picked: [] } }), CTX,
|
|
459
|
+
) as any;
|
|
460
|
+
expect(req.status).toBe('pending');
|
|
461
|
+
expect(req.pending_approvers).toEqual([]);
|
|
462
|
+
expect(engine._tables['sys_approval_request']).toHaveLength(1);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ── decision outputs (#3447 P2) ─────────────────────────────────
|
|
466
|
+
|
|
467
|
+
it('decision outputs: rejected when the node declares none', async () => {
|
|
468
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
469
|
+
await expect(svc.decideNode(req.id, {
|
|
470
|
+
decision: 'approve', actorId: 'u9', outputs: { next: 'u2' },
|
|
471
|
+
}, SYS)).rejects.toThrow(/VALIDATION_FAILED.*declares no decisionOutputs/s);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('decision outputs: rejected when a key is undeclared', async () => {
|
|
475
|
+
const req = await svc.openNodeRequest(
|
|
476
|
+
openInput(['u9'], {}, { decisionOutputs: ['next_reviewers'] }), CTX,
|
|
477
|
+
);
|
|
478
|
+
await expect(svc.decideNode(req.id, {
|
|
479
|
+
decision: 'approve', actorId: 'u9', outputs: { other_key: 1 },
|
|
480
|
+
}, SYS)).rejects.toThrow(/VALIDATION_FAILED.*other_key.*not declared/s);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('decision outputs: reserved keys are rejected even when declared', async () => {
|
|
484
|
+
const req = await svc.openNodeRequest(
|
|
485
|
+
openInput(['u9'], {}, { decisionOutputs: ['decision'] }), CTX,
|
|
486
|
+
);
|
|
487
|
+
await expect(svc.decideNode(req.id, {
|
|
488
|
+
decision: 'approve', actorId: 'u9', outputs: { decision: 'spoofed' },
|
|
489
|
+
}, SYS)).rejects.toThrow(/VALIDATION_FAILED.*reserved/s);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('decision outputs: typed declarations normalize — whitelist by key, defs surfaced for the UI', async () => {
|
|
493
|
+
const req = await svc.openNodeRequest(
|
|
494
|
+
openInput(['u9'], {}, {
|
|
495
|
+
decisionOutputs: [
|
|
496
|
+
{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true },
|
|
497
|
+
'note',
|
|
498
|
+
],
|
|
499
|
+
}), CTX,
|
|
500
|
+
) as any;
|
|
501
|
+
// Key list stays the version-skew-safe shape; defs carry the typed form.
|
|
502
|
+
expect(req.decision_outputs).toEqual(['next_reviewers', 'note']);
|
|
503
|
+
expect(req.decision_output_defs).toEqual([
|
|
504
|
+
{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true },
|
|
505
|
+
{ key: 'note' },
|
|
506
|
+
]);
|
|
507
|
+
// A typed declaration whitelists exactly like a bare key.
|
|
508
|
+
const out = await svc.decideNode(req.id, {
|
|
509
|
+
decision: 'approve', actorId: 'u9', outputs: { next_reviewers: ['u2', 'u3'] },
|
|
510
|
+
}, SYS);
|
|
511
|
+
expect(out.outputs).toEqual({ next_reviewers: ['u2', 'u3'] });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('decision outputs: declared keys surface on the request row as decision_outputs (#3447 P2 UI)', async () => {
|
|
515
|
+
// The decision UI renders one input per declared key — the keys ride the
|
|
516
|
+
// request read (per-request; the static action params can't carry them).
|
|
517
|
+
const req = await svc.openNodeRequest(
|
|
518
|
+
openInput(['u9'], {}, { decisionOutputs: ['next_reviewers', 'note'] }), CTX,
|
|
519
|
+
) as any;
|
|
520
|
+
expect(req.decision_outputs).toEqual(['next_reviewers', 'note']);
|
|
521
|
+
const listed = await svc.listRequests({ status: 'pending' }, SYS);
|
|
522
|
+
expect((listed[0] as any).decision_outputs).toEqual(['next_reviewers', 'note']);
|
|
523
|
+
// A node declaring none omits the field entirely.
|
|
524
|
+
engine._tables['sys_approval_request'] = [];
|
|
525
|
+
const plain = await svc.openNodeRequest(openInput(['u9']), CTX) as any;
|
|
526
|
+
expect(plain.decision_outputs).toBeUndefined();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('decision outputs: accepted keys return from decideNode and snapshot as __decisionOutputs', async () => {
|
|
530
|
+
const req = await svc.openNodeRequest(
|
|
531
|
+
openInput(['u9'], {}, { decisionOutputs: ['next_reviewers', 'note'] }), CTX,
|
|
532
|
+
);
|
|
533
|
+
const out = await svc.decideNode(req.id, {
|
|
534
|
+
decision: 'approve', actorId: 'u9', outputs: { next_reviewers: ['u2', 'u3'] },
|
|
535
|
+
}, SYS);
|
|
536
|
+
expect(out.finalized).toBe(true);
|
|
537
|
+
expect(out.outputs).toEqual({ next_reviewers: ['u2', 'u3'] });
|
|
538
|
+
const raw = engine._tables['sys_approval_request'][0];
|
|
539
|
+
expect(JSON.parse(raw.node_config_json).__decisionOutputs).toEqual({ next_reviewers: ['u2', 'u3'] });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('decision outputs: co-sign votes accumulate, the finalizing decision hands the merged set over', async () => {
|
|
543
|
+
const req = await svc.openNodeRequest(
|
|
544
|
+
openInput(['u1', 'u2'], {}, { behavior: 'unanimous', decisionOutputs: ['legal_note', 'finance_note'] }), CTX,
|
|
545
|
+
);
|
|
546
|
+
const first = await svc.decideNode(req.id, {
|
|
547
|
+
decision: 'approve', actorId: 'u1', outputs: { legal_note: 'ok' },
|
|
548
|
+
}, SYS);
|
|
549
|
+
expect(first.finalized).toBe(false);
|
|
550
|
+
const second = await svc.decideNode(req.id, {
|
|
551
|
+
decision: 'approve', actorId: 'u2', outputs: { finance_note: 'ok too' },
|
|
552
|
+
}, SYS);
|
|
553
|
+
expect(second.finalized).toBe(true);
|
|
554
|
+
expect(second.outputs).toEqual({ legal_note: 'ok', finance_note: 'ok too' });
|
|
555
|
+
});
|
|
556
|
+
|
|
190
557
|
// ── approver expansion: position (ADR-0090 D3) ──────────────────
|
|
191
558
|
|
|
192
559
|
const positionInput = (extra: Record<string, any> = {}) => ({
|
|
@@ -297,6 +664,69 @@ describe('ApprovalService (node era)', () => {
|
|
|
297
664
|
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
298
665
|
});
|
|
299
666
|
|
|
667
|
+
// #3807 — an app's org tree is normally SEEDED, and a seed cannot know the
|
|
668
|
+
// organization id the runtime mints at boot, so those rows carry
|
|
669
|
+
// `organization_id = null` while every approval request carries an org. The
|
|
670
|
+
// old strict equality made each of them invisible and every `department`
|
|
671
|
+
// approver resolved to the dead `department:<id>` literal.
|
|
672
|
+
const departmentInput = (value: string) => positionInput({
|
|
673
|
+
config: {
|
|
674
|
+
approvers: [{ type: 'department' as const, value }],
|
|
675
|
+
behavior: 'first_response' as const,
|
|
676
|
+
lockRecord: true,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('department approver: an env-wide (null-org) business unit still resolves (#3807)', async () => {
|
|
681
|
+
engine._tables['sys_business_unit'] = [
|
|
682
|
+
{ id: 'bu_seeded', organization_id: null, active: true },
|
|
683
|
+
{ id: 'bu_seeded_child', parent_business_unit_id: 'bu_seeded', organization_id: null, active: true },
|
|
684
|
+
];
|
|
685
|
+
engine._tables['sys_business_unit_member'] = [
|
|
686
|
+
{ id: 'bm1', business_unit_id: 'bu_seeded', user_id: 'u5' },
|
|
687
|
+
{ id: 'bm2', business_unit_id: 'bu_seeded_child', user_id: 'u6' },
|
|
688
|
+
];
|
|
689
|
+
const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
|
|
690
|
+
// Both the seed check AND the subtree descent must see the null-org rows.
|
|
691
|
+
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('department approver: another organization’s unit stays invisible (#3807 keeps the wall)', async () => {
|
|
695
|
+
engine._tables['sys_business_unit'] = [
|
|
696
|
+
{ id: 'bu_other', organization_id: 't2', active: true },
|
|
697
|
+
{ id: 'bu_other_child', parent_business_unit_id: 'bu_other', organization_id: 't2', active: true },
|
|
698
|
+
];
|
|
699
|
+
engine._tables['sys_business_unit_member'] = [
|
|
700
|
+
{ id: 'bm1', business_unit_id: 'bu_other', user_id: 'intruder' },
|
|
701
|
+
{ id: 'bm2', business_unit_id: 'bu_other_child', user_id: 'intruder2' },
|
|
702
|
+
];
|
|
703
|
+
const req = await svc.openNodeRequest(departmentInput('bu_other'), CTX);
|
|
704
|
+
expect(req.pending_approvers).toEqual(['department:bu_other']);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('department approver: a null-org subtree does not drag in another org’s child unit (#3807)', async () => {
|
|
708
|
+
engine._tables['sys_business_unit'] = [
|
|
709
|
+
{ id: 'bu_seeded', organization_id: null, active: true },
|
|
710
|
+
{ id: 'bu_mine', parent_business_unit_id: 'bu_seeded', organization_id: 't1', active: true },
|
|
711
|
+
{ id: 'bu_theirs', parent_business_unit_id: 'bu_seeded', organization_id: 't2', active: true },
|
|
712
|
+
];
|
|
713
|
+
engine._tables['sys_business_unit_member'] = [
|
|
714
|
+
{ id: 'bm1', business_unit_id: 'bu_mine', user_id: 'u5' },
|
|
715
|
+
{ id: 'bm2', business_unit_id: 'bu_theirs', user_id: 'intruder' },
|
|
716
|
+
];
|
|
717
|
+
const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
|
|
718
|
+
expect(req.pending_approvers).toEqual(['u5']);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('department approver: an inactive env-wide unit still contributes nobody (#3807)', async () => {
|
|
722
|
+
engine._tables['sys_business_unit'] = [{ id: 'bu_seeded', organization_id: null, active: false }];
|
|
723
|
+
engine._tables['sys_business_unit_member'] = [
|
|
724
|
+
{ id: 'bm1', business_unit_id: 'bu_seeded', user_id: 'u5' },
|
|
725
|
+
];
|
|
726
|
+
const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
|
|
727
|
+
expect(req.pending_approvers).toEqual(['department:bu_seeded']);
|
|
728
|
+
});
|
|
729
|
+
|
|
300
730
|
// ── decideNode ──────────────────────────────────────────────────
|
|
301
731
|
|
|
302
732
|
it('decideNode: first_response approve finalizes immediately', async () => {
|
|
@@ -418,11 +848,13 @@ describe('ApprovalService (node era)', () => {
|
|
|
418
848
|
it('getRequest: viewer.can_act is true for a pending approver, false for the submitter', async () => {
|
|
419
849
|
const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
|
|
420
850
|
const asApprover = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
421
|
-
expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false });
|
|
851
|
+
expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false, can_override: false });
|
|
422
852
|
const asSubmitter = await svc.getRequest(req.id, { userId: 'u1', tenantId: 't1' } as any);
|
|
423
|
-
expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true });
|
|
424
|
-
|
|
425
|
-
|
|
853
|
+
expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true, can_override: false });
|
|
854
|
+
// #3590: a same-tenant stranger participates in nothing, so the request is
|
|
855
|
+
// not readable at all — previously it came back with an all-false viewer
|
|
856
|
+
// block, which meant every authenticated user could read every request.
|
|
857
|
+
expect(await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any)).toBeNull();
|
|
426
858
|
});
|
|
427
859
|
|
|
428
860
|
it('getRequest: viewer.can_act drops to false once the request is finalized', async () => {
|
|
@@ -539,6 +971,27 @@ describe('ApprovalService (node era)', () => {
|
|
|
539
971
|
expect(rows[0].payload_display).toEqual({ account: 'Acme Corp' });
|
|
540
972
|
});
|
|
541
973
|
|
|
974
|
+
it('enrichment maps snapshot field keys to the object field labels', async () => {
|
|
975
|
+
(engine as any).getSchema = (name: string) =>
|
|
976
|
+
name === 'opportunity'
|
|
977
|
+
? {
|
|
978
|
+
label: 'Opportunity',
|
|
979
|
+
fields: {
|
|
980
|
+
id: {}, // no label → excluded from payload_labels
|
|
981
|
+
name: { label: 'Deal Name' },
|
|
982
|
+
amount: { label: 'Deal Amount' },
|
|
983
|
+
},
|
|
984
|
+
}
|
|
985
|
+
: undefined;
|
|
986
|
+
await svc.openNodeRequest(
|
|
987
|
+
openInput(['u9'], { record: { id: 'opp1', name: 'Acme Renewal', amount: 100 } }), CTX,
|
|
988
|
+
);
|
|
989
|
+
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
990
|
+
// Only keys present in the snapshot AND carrying a schema label are mapped;
|
|
991
|
+
// `id` (unlabeled) is dropped.
|
|
992
|
+
expect(rows[0].payload_labels).toEqual({ name: 'Deal Name', amount: 'Deal Amount' });
|
|
993
|
+
});
|
|
994
|
+
|
|
542
995
|
it('enrichment maps user-id approvers to display names', async () => {
|
|
543
996
|
engine._tables['sys_user'] = [{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }];
|
|
544
997
|
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
@@ -561,7 +1014,7 @@ describe('ApprovalService (node era)', () => {
|
|
|
561
1014
|
|
|
562
1015
|
it('reassign: hands the slot to a new approver and audits the move', async () => {
|
|
563
1016
|
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
|
|
564
|
-
const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' },
|
|
1017
|
+
const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
|
|
565
1018
|
expect(out.request.pending_approvers).toEqual(['u7', 'u2']);
|
|
566
1019
|
const actions = await svc.listActions(req.id, SYS);
|
|
567
1020
|
expect(actions.at(-1)).toMatchObject({ action: 'reassign', actor_id: 'u9', comment: 'u9 → u7' });
|
|
@@ -571,15 +1024,15 @@ describe('ApprovalService (node era)', () => {
|
|
|
571
1024
|
const emitted: any[] = [];
|
|
572
1025
|
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
573
1026
|
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
574
|
-
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' },
|
|
1027
|
+
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
|
|
575
1028
|
expect(emitted).toHaveLength(1);
|
|
576
1029
|
expect(emitted[0]).toMatchObject({ topic: 'approval.reassigned', audience: ['u7'] });
|
|
577
1030
|
});
|
|
578
1031
|
|
|
579
1032
|
it('reassign: blocks a non-holder and duplicate targets', async () => {
|
|
580
1033
|
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
|
|
581
|
-
await expect(svc.reassign(req.id, { actorId: 'intruder', to: 'u7' },
|
|
582
|
-
await expect(svc.reassign(req.id, { actorId: 'u9', to: 'u2' },
|
|
1034
|
+
await expect(svc.reassign(req.id, { actorId: 'intruder', to: 'u7' }, asUser('intruder'))).rejects.toThrow(/FORBIDDEN/);
|
|
1035
|
+
await expect(svc.reassign(req.id, { actorId: 'u9', to: 'u2' }, asUser('u9'))).rejects.toThrow(/VALIDATION_FAILED/);
|
|
583
1036
|
});
|
|
584
1037
|
|
|
585
1038
|
it('remind: notifies pending approvers, audits, and throttles repeats', async () => {
|
|
@@ -628,7 +1081,7 @@ describe('ApprovalService (node era)', () => {
|
|
|
628
1081
|
const emitted: any[] = [];
|
|
629
1082
|
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
630
1083
|
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
631
|
-
const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' },
|
|
1084
|
+
const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' }, asUser('u9'));
|
|
632
1085
|
expect(out.request.status).toBe('pending');
|
|
633
1086
|
expect(out.request.pending_approvers).toEqual(['u9']);
|
|
634
1087
|
expect(emitted[0]).toMatchObject({ topic: 'approval.request_info', audience: ['u1'] });
|
|
@@ -639,8 +1092,8 @@ describe('ApprovalService (node era)', () => {
|
|
|
639
1092
|
it('comment: submitter and approver may reply; outsiders may not', async () => {
|
|
640
1093
|
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
641
1094
|
await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX);
|
|
642
|
-
await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' },
|
|
643
|
-
await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' },
|
|
1095
|
+
await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, asUser('u9'));
|
|
1096
|
+
await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, asUser('outsider')))
|
|
644
1097
|
.rejects.toThrow(/FORBIDDEN/);
|
|
645
1098
|
const actions = await svc.listActions(req.id, SYS);
|
|
646
1099
|
expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
|
|
@@ -692,7 +1145,7 @@ describe('ApprovalService (node era)', () => {
|
|
|
692
1145
|
expect(await svc.redeemActionToken(short.approve)).toMatchObject({ ok: false, reason: 'expired' });
|
|
693
1146
|
|
|
694
1147
|
const live = await svc.issueActionTokens(req.id, 'u9');
|
|
695
|
-
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' },
|
|
1148
|
+
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
|
|
696
1149
|
expect(await svc.redeemActionToken(live.approve)).toMatchObject({ ok: false, reason: 'not_approver' });
|
|
697
1150
|
|
|
698
1151
|
const forU7 = await svc.issueActionTokens(req.id, 'u7');
|
|
@@ -1034,6 +1487,127 @@ describe('ApprovalService (node era)', () => {
|
|
|
1034
1487
|
});
|
|
1035
1488
|
});
|
|
1036
1489
|
|
|
1490
|
+
// ── Admin / privileged override (#3424) ──────────────────────────────
|
|
1491
|
+
//
|
|
1492
|
+
// An approval routed to a position/team with NO holders resolves to only the
|
|
1493
|
+
// unresolvable `position:<name>` literal — no concrete user is in the slate, so
|
|
1494
|
+
// every normal decision is FORBIDDEN and (with lockRecord) the record stays
|
|
1495
|
+
// locked forever with no in-product recovery. A platform or tenant admin may
|
|
1496
|
+
// act on the pending request to release it: approve, reject, reassign it to a
|
|
1497
|
+
// real approver, or recall it. Privilege is org-scoped for tenant admins.
|
|
1498
|
+
describe('ApprovalService — admin override (#3424)', () => {
|
|
1499
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1500
|
+
let svc: ApprovalService;
|
|
1501
|
+
let n = 0;
|
|
1502
|
+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
1503
|
+
|
|
1504
|
+
// Admin exec contexts, shaped like the resolved authz envelope (permissions
|
|
1505
|
+
// carry the permission-set names the shared resolver aggregates).
|
|
1506
|
+
const PLATFORM_ADMIN = { userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'] } as any;
|
|
1507
|
+
const TENANT_ADMIN = { userId: 'owner', tenantId: 't1', positions: [], permissions: ['organization_admin'] } as any;
|
|
1508
|
+
const OTHER_TENANT_ADMIN = { userId: 'owner2', tenantId: 't2', positions: [], permissions: ['organization_admin'] } as any;
|
|
1509
|
+
const MEMBER = { userId: 'nobody', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
1510
|
+
|
|
1511
|
+
// A request routed to an UNSTAFFED position → `pending_approvers` falls back to
|
|
1512
|
+
// the `position:sales_manager` literal, undecidable by any normal user.
|
|
1513
|
+
const stuckInput = (extra: Record<string, any> = {}) => ({
|
|
1514
|
+
object: 'opportunity', recordId: 'opp1', runId: 'run_1', nodeId: 'approve_step',
|
|
1515
|
+
flowName: 'deal_approval',
|
|
1516
|
+
config: { approvers: [{ type: 'position' as const, value: 'sales_manager' }], behavior: 'first_response' as const, lockRecord: true },
|
|
1517
|
+
record: { id: 'opp1', amount: 100 },
|
|
1518
|
+
...extra,
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
beforeEach(() => {
|
|
1522
|
+
engine = makeFakeEngine();
|
|
1523
|
+
n = 0;
|
|
1524
|
+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
it('the stuck request is undecidable by any normal user (repro)', async () => {
|
|
1528
|
+
const req = await svc.openNodeRequest(stuckInput(), CTX);
|
|
1529
|
+
expect(req.pending_approvers).toEqual(['position:sales_manager']);
|
|
1530
|
+
// Even the org owner-by-id is not in the resolved (empty) slate.
|
|
1531
|
+
await expect(svc.decideNode(req.id, { decision: 'approve', actorId: 'nobody' }, MEMBER))
|
|
1532
|
+
.rejects.toThrow(/FORBIDDEN/);
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
it('a tenant admin can approve a stuck request, finalizing it (which releases the lock)', async () => {
|
|
1536
|
+
const req = await svc.openNodeRequest(stuckInput(), CTX);
|
|
1537
|
+
const out = await svc.decide(req.id, { decision: 'approve', actorId: 'owner' }, TENANT_ADMIN);
|
|
1538
|
+
expect(out.finalized).toBe(true);
|
|
1539
|
+
expect(out.request.status).toBe('approved');
|
|
1540
|
+
// No pending request remains → the record-lock hook no longer blocks edits.
|
|
1541
|
+
const fresh = await svc.getRequest(req.id, SYS);
|
|
1542
|
+
expect(fresh?.status).toBe('approved');
|
|
1543
|
+
expect(fresh?.pending_approvers).toEqual([]);
|
|
1544
|
+
// Audited under the admin's own id — never spoofed as an approver.
|
|
1545
|
+
const acts = await svc.listActions(req.id, SYS);
|
|
1546
|
+
expect(acts.at(-1)).toMatchObject({ action: 'approve', actor_id: 'owner' });
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it('a platform admin can reject a stuck request', async () => {
|
|
1550
|
+
const req = await svc.openNodeRequest(stuckInput(), CTX);
|
|
1551
|
+
const out = await svc.decide(req.id, { decision: 'reject', actorId: 'root' }, PLATFORM_ADMIN);
|
|
1552
|
+
expect(out.finalized).toBe(true);
|
|
1553
|
+
expect(out.request.status).toBe('rejected');
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
it('an admin override finalizes even a unanimous request immediately (not one vote among the slate)', async () => {
|
|
1557
|
+
const req = await svc.openNodeRequest(stuckInput({
|
|
1558
|
+
config: { approvers: [{ type: 'position' as const, value: 'sales_manager' }], behavior: 'unanimous' as const, lockRecord: true },
|
|
1559
|
+
}), CTX);
|
|
1560
|
+
const out = await svc.decide(req.id, { decision: 'approve', actorId: 'owner' }, TENANT_ADMIN);
|
|
1561
|
+
expect(out.finalized).toBe(true);
|
|
1562
|
+
expect(out.request.status).toBe('approved');
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
it('an admin can reassign a stuck request to a real approver, who then decides normally', async () => {
|
|
1566
|
+
const req = await svc.openNodeRequest(stuckInput(), CTX);
|
|
1567
|
+
const out = await svc.reassign(req.id, { actorId: 'owner', to: 'u7' }, TENANT_ADMIN);
|
|
1568
|
+
expect(out.request.pending_approvers).toEqual(['u7']);
|
|
1569
|
+
const decided = await svc.decideNode(
|
|
1570
|
+
req.id, { decision: 'approve', actorId: 'u7' },
|
|
1571
|
+
{ userId: 'u7', tenantId: 't1', positions: [], permissions: [] } as any,
|
|
1572
|
+
);
|
|
1573
|
+
expect(decided.finalized).toBe(true);
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it('an admin can recall (withdraw) a stuck request', async () => {
|
|
1577
|
+
const req = await svc.openNodeRequest(stuckInput(), CTX);
|
|
1578
|
+
const out = await svc.recall(req.id, { actorId: 'owner', comment: 'unstaffed role' }, TENANT_ADMIN);
|
|
1579
|
+
expect(out.request.status).toBe('recalled');
|
|
1580
|
+
expect(out.request.pending_approvers).toEqual([]);
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
it('a tenant admin of a DIFFERENT org cannot override (privilege is org-scoped)', async () => {
|
|
1584
|
+
const req = await svc.openNodeRequest(stuckInput(), CTX); // organization_id = t1
|
|
1585
|
+
await expect(svc.decideNode(req.id, { decision: 'approve', actorId: 'owner2' }, OTHER_TENANT_ADMIN))
|
|
1586
|
+
.rejects.toThrow(/FORBIDDEN/);
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
it('viewer.can_override reflects the privilege, and drops once finalized', async () => {
|
|
1590
|
+
const req = await svc.openNodeRequest(stuckInput(), CTX);
|
|
1591
|
+
const asAdmin = await svc.getRequest(req.id, TENANT_ADMIN);
|
|
1592
|
+
expect(asAdmin!.viewer).toMatchObject({ can_act: false, can_override: true });
|
|
1593
|
+
// Someone who CAN see the request but holds no override privilege — the
|
|
1594
|
+
// submitter. `can_override` is about the privilege, not about access, so
|
|
1595
|
+
// the check needs a participant rather than a stranger.
|
|
1596
|
+
const asSubmitter = await svc.getRequest(req.id, CTX);
|
|
1597
|
+
expect(asSubmitter!.viewer!.can_override).toBe(false);
|
|
1598
|
+
await svc.decide(req.id, { decision: 'approve', actorId: 'owner' }, TENANT_ADMIN);
|
|
1599
|
+
const after = await svc.getRequest(req.id, TENANT_ADMIN);
|
|
1600
|
+
expect(after!.viewer!.can_override).toBe(false);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
// #3590: a plain member who participates in nothing now sees nothing — a
|
|
1604
|
+
// request is no longer readable merely because you are in the same tenant.
|
|
1605
|
+
it('a non-participant member cannot read the request at all', async () => {
|
|
1606
|
+
const req = await svc.openNodeRequest(stuckInput(), CTX);
|
|
1607
|
+
expect(await svc.getRequest(req.id, MEMBER)).toBeNull();
|
|
1608
|
+
});
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1037
1611
|
describe('record-lock hook (node era)', () => {
|
|
1038
1612
|
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1039
1613
|
let svc: ApprovalService;
|
|
@@ -1098,34 +1672,227 @@ describe('record-lock hook (node era)', () => {
|
|
|
1098
1672
|
).resolves.toBeUndefined();
|
|
1099
1673
|
});
|
|
1100
1674
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1675
|
+
// ── #3456 prevention half: the lock must not kill the run that owns it ──
|
|
1676
|
+
|
|
1677
|
+
it('allows the OWNING run to write its own target record', async () => {
|
|
1678
|
+
await expect(
|
|
1679
|
+
engine.fire('beforeUpdate', {
|
|
1680
|
+
object: 'opportunity',
|
|
1681
|
+
input: { id: 'opp1', data: { amount: 200 } },
|
|
1682
|
+
// Neither elevated nor admin — the exemption rides on run identity
|
|
1683
|
+
// alone, so a `runAs:'user'` run stays RLS-scoped while it writes.
|
|
1684
|
+
session: { isSystem: false, positions: [], userId: 'u1' },
|
|
1685
|
+
provenance: { flowRunId: 'run_1' },
|
|
1686
|
+
}),
|
|
1687
|
+
).resolves.toBeUndefined();
|
|
1104
1688
|
});
|
|
1105
|
-
});
|
|
1106
1689
|
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1690
|
+
// #3712 — the residual #3703 left open. A schedule-triggered run resolves NO
|
|
1691
|
+
// principal, so it arrives with no session at all; provenance is the only
|
|
1692
|
+
// thing it carries, and the exemption must key on that rather than on an
|
|
1693
|
+
// identity the run does not have.
|
|
1694
|
+
it('allows an identity-less (schedule-triggered) owning run — no session at all', async () => {
|
|
1695
|
+
await expect(
|
|
1696
|
+
engine.fire('beforeUpdate', {
|
|
1697
|
+
object: 'opportunity',
|
|
1698
|
+
input: { id: 'opp1', data: { amount: 200 } },
|
|
1699
|
+
provenance: { flowRunId: 'run_1' },
|
|
1700
|
+
}),
|
|
1701
|
+
).resolves.toBeUndefined();
|
|
1702
|
+
});
|
|
1119
1703
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1704
|
+
it('still blocks a DIFFERENT run writing the locked record', async () => {
|
|
1705
|
+
await expect(
|
|
1706
|
+
engine.fire('beforeUpdate', {
|
|
1707
|
+
object: 'opportunity',
|
|
1708
|
+
input: { id: 'opp1', data: { amount: 200 } },
|
|
1709
|
+
session: { isSystem: false, positions: [], userId: 'u1' },
|
|
1710
|
+
provenance: { flowRunId: 'run_other' },
|
|
1711
|
+
}),
|
|
1712
|
+
).rejects.toThrow(/RECORD_LOCKED/);
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
it('still blocks an identity-less caller with no provenance at all', async () => {
|
|
1716
|
+
// The bare-kernel / no-context write. Nothing to match, nothing exempted.
|
|
1717
|
+
await expect(
|
|
1718
|
+
engine.fire('beforeUpdate', {
|
|
1719
|
+
object: 'opportunity',
|
|
1720
|
+
input: { id: 'opp1', data: { amount: 200 } },
|
|
1721
|
+
}),
|
|
1722
|
+
).rejects.toThrow(/RECORD_LOCKED/);
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it('does not exempt anyone when the pending request carries no run id', async () => {
|
|
1726
|
+
// A request with no owning run has nothing to match against — a stray
|
|
1727
|
+
// `flowRunId` must not become a skeleton key.
|
|
1728
|
+
engine._tables['sys_approval_request'][0].flow_run_id = null;
|
|
1729
|
+
await expect(
|
|
1730
|
+
engine.fire('beforeUpdate', {
|
|
1731
|
+
object: 'opportunity',
|
|
1732
|
+
input: { id: 'opp1', data: { amount: 200 } },
|
|
1733
|
+
session: { isSystem: false, positions: [], userId: 'u1' },
|
|
1734
|
+
provenance: { flowRunId: 'run_1' },
|
|
1735
|
+
}),
|
|
1736
|
+
).rejects.toThrow(/RECORD_LOCKED/);
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it('unbindAllHooks removes the lock hook', () => {
|
|
1740
|
+
expect(unbindAllHooks(engine as any)).toBe(1);
|
|
1741
|
+
expect(engine._hooks['beforeUpdate']).toHaveLength(0);
|
|
1742
|
+
});
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
// ── #3456 recovery half: release records held by a dead approval run ──
|
|
1746
|
+
//
|
|
1747
|
+
// The prevention half above stops a run from dying on its own lock. This sweep
|
|
1748
|
+
// covers the runs that die anyway — including a process crash, which no in-band
|
|
1749
|
+
// handler can clean up because the process that would run it is gone.
|
|
1750
|
+
//
|
|
1751
|
+
// The load-bearing property is what it must NOT do: a run merely *paused* on its
|
|
1752
|
+
// approval is the normal state of every live request, so anything short of an
|
|
1753
|
+
// explicit terminal status has to be read as "alive".
|
|
1754
|
+
describe('ApprovalService — dead-run release (#3456)', () => {
|
|
1755
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1756
|
+
let svc: ApprovalService;
|
|
1757
|
+
let n = 0;
|
|
1758
|
+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
1759
|
+
|
|
1760
|
+
/** Attach an automation surface whose `getRun` answers with `status`. */
|
|
1761
|
+
const withRunStatus = (status: string | null) =>
|
|
1762
|
+
svc.attachAutomation({ getRun: async () => (status == null ? null : { status }) } as any);
|
|
1763
|
+
|
|
1764
|
+
const requestRow = () => engine._tables['sys_approval_request'][0];
|
|
1765
|
+
|
|
1766
|
+
beforeEach(async () => {
|
|
1767
|
+
engine = makeFakeEngine();
|
|
1768
|
+
n = 0;
|
|
1769
|
+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
|
|
1770
|
+
bindApprovalLockHook(engine as any);
|
|
1771
|
+
await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
1772
|
+
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it('releases a pending request whose owning run failed', async () => {
|
|
1776
|
+
withRunStatus('failed');
|
|
1777
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 1 });
|
|
1778
|
+
expect(requestRow().status).toBe('recalled');
|
|
1779
|
+
expect(requestRow().pending_approvers).toBeNull();
|
|
1780
|
+
expect(requestRow().completed_at).toBeTruthy();
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
it('audits the release as a dead-run abandonment, not a submitter recall', async () => {
|
|
1784
|
+
withRunStatus('failed');
|
|
1785
|
+
await svc.releaseDeadRunRequests();
|
|
1786
|
+
const action = engine._tables['sys_approval_action'].find((a: any) => a.actor_id === 'system:dead-run');
|
|
1787
|
+
expect(action).toBeTruthy();
|
|
1788
|
+
expect(action.action).toBe('recall');
|
|
1789
|
+
expect(action.comment).toMatch(/run_1/);
|
|
1790
|
+
expect(action.comment).toMatch(/failed/);
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
it('actually unlocks the record — a plain user edit succeeds afterwards', async () => {
|
|
1794
|
+
// The end-to-end point of the whole sweep.
|
|
1795
|
+
const edit = () => engine.fire('beforeUpdate', {
|
|
1796
|
+
object: 'opportunity',
|
|
1797
|
+
input: { id: 'opp1', data: { amount: 200 } },
|
|
1798
|
+
session: { isSystem: false, positions: [], userId: 'u1' },
|
|
1799
|
+
});
|
|
1800
|
+
await expect(edit()).rejects.toThrow(/RECORD_LOCKED/); // held by the dead run
|
|
1801
|
+
withRunStatus('failed');
|
|
1802
|
+
await svc.releaseDeadRunRequests();
|
|
1803
|
+
await expect(edit()).resolves.toBeUndefined(); // released
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
it('mirrors the configured status field on release', async () => {
|
|
1807
|
+
withRunStatus('failed');
|
|
1808
|
+
await svc.releaseDeadRunRequests();
|
|
1809
|
+
expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it('leaves a PAUSED run alone — that is a live approval', async () => {
|
|
1813
|
+
withRunStatus('paused');
|
|
1814
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
|
|
1815
|
+
expect(requestRow().status).toBe('pending');
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
it.each([
|
|
1819
|
+
['an unknown run (null)', null],
|
|
1820
|
+
['an unrecognised status', 'reticulating_splines'],
|
|
1821
|
+
['a still-running run', 'running'],
|
|
1822
|
+
])('leaves the request pending for %s', async (_label, status) => {
|
|
1823
|
+
withRunStatus(status as any);
|
|
1824
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
|
|
1825
|
+
expect(requestRow().status).toBe('pending');
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
it('leaves the request pending when getRun throws', async () => {
|
|
1829
|
+
svc.attachAutomation({ getRun: async () => { throw new Error('engine unreachable'); } } as any);
|
|
1830
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
|
|
1831
|
+
expect(requestRow().status).toBe('pending');
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
it('is a no-op with no automation engine attached', async () => {
|
|
1835
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 0, released: 0 });
|
|
1836
|
+
expect(requestRow().status).toBe('pending');
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
it('is a no-op when the surface has no getRun (older engine)', async () => {
|
|
1840
|
+
svc.attachAutomation({ resume: async () => undefined } as any);
|
|
1841
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 0, released: 0 });
|
|
1842
|
+
expect(requestRow().status).toBe('pending');
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
it('skips a request with no owning run', async () => {
|
|
1846
|
+
requestRow().flow_run_id = null;
|
|
1847
|
+
withRunStatus('failed');
|
|
1848
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
|
|
1849
|
+
expect(requestRow().status).toBe('pending');
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
it.each(['completed', 'cancelled', 'timed_out'])(
|
|
1853
|
+
'releases on the other terminal status %s', async (status) => {
|
|
1854
|
+
// A terminal run can never decide its request, whatever ended it — a
|
|
1855
|
+
// `completed` one means someone resumed the run out of band.
|
|
1856
|
+
withRunStatus(status);
|
|
1857
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 1 });
|
|
1858
|
+
expect(requestRow().status).toBe('recalled');
|
|
1859
|
+
},
|
|
1860
|
+
);
|
|
1861
|
+
|
|
1862
|
+
it('one unreadable request does not stop the sweep', async () => {
|
|
1863
|
+
await svc.openNodeRequest(
|
|
1864
|
+
{ ...openInput(['u9']), recordId: 'opp2', runId: 'run_2' } as any, CTX,
|
|
1865
|
+
);
|
|
1866
|
+
let call = 0;
|
|
1867
|
+
svc.attachAutomation({
|
|
1868
|
+
getRun: async () => { call++; if (call === 1) throw new Error('boom'); return { status: 'failed' }; },
|
|
1869
|
+
} as any);
|
|
1870
|
+
expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 2, released: 1 });
|
|
1871
|
+
});
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
// ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
|
|
1875
|
+
//
|
|
1876
|
+
// When a resolved individual approver has declared an active OOO delegation,
|
|
1877
|
+
// the slot is rerouted to the delegate at resolution time (never a background
|
|
1878
|
+
// job), audited as `ooo_substitute`, and both parties are notified. Group /
|
|
1879
|
+
// graph approvers (position/team/department/tier) are left untouched.
|
|
1880
|
+
describe('ApprovalService — out-of-office delegation (#1322)', () => {
|
|
1881
|
+
// Mid-window instant for the issue's own example (leave 5/26–5/30).
|
|
1882
|
+
const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
|
|
1883
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1884
|
+
let svc: ApprovalService;
|
|
1885
|
+
let emitted: any[];
|
|
1886
|
+
|
|
1887
|
+
function seedDelegation(rows: Array<Record<string, any>>) {
|
|
1888
|
+
engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
|
|
1889
|
+
id: `del${i}`,
|
|
1890
|
+
organization_id: 't1',
|
|
1891
|
+
valid_from: '2026-05-26T00:00:00Z',
|
|
1892
|
+
valid_until: '2026-05-30T00:00:00Z',
|
|
1893
|
+
reason: 'Annual leave',
|
|
1894
|
+
...r,
|
|
1895
|
+
}));
|
|
1129
1896
|
}
|
|
1130
1897
|
|
|
1131
1898
|
beforeEach(() => {
|
|
@@ -1183,6 +1950,20 @@ describe('ApprovalService — out-of-office delegation (#1322)', () => {
|
|
|
1183
1950
|
expect(req.pending_approvers).toEqual(['bob']);
|
|
1184
1951
|
});
|
|
1185
1952
|
|
|
1953
|
+
it('type:field (multi-select) — reroutes each out-of-office user independently (#3447)', async () => {
|
|
1954
|
+
// Pre-#3447 the array was stringified to one bogus id ('alice,dave'), which
|
|
1955
|
+
// matched no delegation — OOO silently no-op'd. Fanned out, alice → bob
|
|
1956
|
+
// applies and dave is left untouched.
|
|
1957
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1958
|
+
const input = {
|
|
1959
|
+
...openInput([]),
|
|
1960
|
+
record: { id: 'opp1', reviewers: ['alice', 'dave'] },
|
|
1961
|
+
config: { approvers: [{ type: 'field', value: 'reviewers' }], behavior: 'unanimous', lockRecord: true },
|
|
1962
|
+
};
|
|
1963
|
+
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1964
|
+
expect(req.pending_approvers.sort()).toEqual(['bob', 'dave']);
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1186
1967
|
it('type:manager — reroutes when the resolved manager is out of office', async () => {
|
|
1187
1968
|
engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
|
|
1188
1969
|
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
@@ -1451,6 +2232,33 @@ describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () =
|
|
|
1451
2232
|
expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
|
|
1452
2233
|
});
|
|
1453
2234
|
|
|
2235
|
+
it('per_group: pending_approver_groups maps each pending approver to its group (objectui#2807)', async () => {
|
|
2236
|
+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
2237
|
+
let row: any = await svc.getRequest(req.id, SYS);
|
|
2238
|
+
// Every pending slot is labeled with the group it fills.
|
|
2239
|
+
expect(row.pending_approver_groups).toEqual({ l1: ['legal'], f1: ['finance'] });
|
|
2240
|
+
// Once legal signs off, l1 drops out of pending — and out of the map.
|
|
2241
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
2242
|
+
row = await svc.getRequest(req.id, SYS);
|
|
2243
|
+
expect(row.pending_approver_groups).toEqual({ f1: ['finance'] });
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
it('per_group with unnamed groups omits synthetic keys; non-per_group omits the map (objectui#2807)', async () => {
|
|
2247
|
+
// Distinct (record, run) so the two opens aren't a duplicate-pending clash.
|
|
2248
|
+
const mk = (approvers: any[], behavior: string, recordId: string, runId: string, extra: Record<string, any> = {}) => ({
|
|
2249
|
+
...openInput([], { recordId, runId }),
|
|
2250
|
+
config: { approvers, behavior, lockRecord: true, ...extra },
|
|
2251
|
+
});
|
|
2252
|
+
// Unnamed approvers → synthetic `#N` group keys, which are not surfaced.
|
|
2253
|
+
const unnamed = await svc.openNodeRequest(mk([U('u1'), U('u2')], 'per_group', 'opp_u', 'run_u'), CTX);
|
|
2254
|
+
const uRow: any = await svc.getRequest(unnamed.id, SYS);
|
|
2255
|
+
expect(uRow.pending_approver_groups).toBeUndefined();
|
|
2256
|
+
// Quorum aggregates approvals, not groups — no approver→group map.
|
|
2257
|
+
const q = await svc.openNodeRequest(mk([U('a1'), U('a2')], 'quorum', 'opp_q', 'run_q', { minApprovals: 2 }), CTX);
|
|
2258
|
+
const qRow: any = await svc.getRequest(q.id, SYS);
|
|
2259
|
+
expect(qRow.pending_approver_groups).toBeUndefined();
|
|
2260
|
+
});
|
|
2261
|
+
|
|
1454
2262
|
it('quorum: progress reports approvals against the clamped threshold', async () => {
|
|
1455
2263
|
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
1456
2264
|
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
@@ -1477,7 +2285,7 @@ describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () =
|
|
|
1477
2285
|
// listActions must surface decision attachments through the contract mapping
|
|
1478
2286
|
// (#3266 — the column existed but rowFromAction dropped it; caught in browser).
|
|
1479
2287
|
describe('ApprovalService — listActions attachments mapping (#3266)', () => {
|
|
1480
|
-
it('
|
|
2288
|
+
it('normalizes a bare fileId string into an attachment descriptor', async () => {
|
|
1481
2289
|
const engine = makeFakeEngine();
|
|
1482
2290
|
let n = 0;
|
|
1483
2291
|
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
|
|
@@ -1485,6 +2293,566 @@ describe('ApprovalService — listActions attachments mapping (#3266)', () => {
|
|
|
1485
2293
|
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
|
|
1486
2294
|
const acts = await svc.listActions(req.id, SYS);
|
|
1487
2295
|
const approve = acts.find(a => a.action === 'approve');
|
|
1488
|
-
expect(approve?.attachments).toEqual(['file_a']);
|
|
2296
|
+
expect(approve?.attachments).toEqual([{ id: 'file_a' }]);
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
// The normal case. The column STORES an opaque sys_file id (ADR-0104 D3);
|
|
2300
|
+
// the ObjectQL read path resolves it into the expanded
|
|
2301
|
+
// `{ id, name, size, mimeType, url }` form on the way out. The old
|
|
2302
|
+
// `.map(String)` turned that object into "[object Object]", so the inbox chip
|
|
2303
|
+
// had no name and 404'd on open. The mapping must pass it through unmangled.
|
|
2304
|
+
it('passes the engine-expanded file value through with its name and url', async () => {
|
|
2305
|
+
const engine = makeFakeEngine();
|
|
2306
|
+
let n = 0;
|
|
2307
|
+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
|
|
2308
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2309
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
2310
|
+
// Simulate what the read path hands back after resolving the stored id.
|
|
2311
|
+
const row = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
|
|
2312
|
+
row.attachments = [
|
|
2313
|
+
{ id: 'file_a', name: 'signed-contract.pdf', mimeType: 'application/pdf', size: 24, url: '/api/v1/storage/files/file_a' },
|
|
2314
|
+
];
|
|
2315
|
+
const acts = await svc.listActions(req.id, SYS);
|
|
2316
|
+
const approve = acts.find(a => a.action === 'approve');
|
|
2317
|
+
expect(approve?.attachments).toEqual([
|
|
2318
|
+
{ id: 'file_a', name: 'signed-contract.pdf', mimeType: 'application/pdf', size: 24, url: '/api/v1/storage/files/file_a' },
|
|
2319
|
+
]);
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
// Rows written before file-as-reference hold an inline blob whose keys are
|
|
2323
|
+
// snake_case (`file_id`, `mime_type`). They stay readable until the backfill
|
|
2324
|
+
// converts them, so both casings must map — the same drift that made objectui
|
|
2325
|
+
// stop recognising images when the expanded form arrived.
|
|
2326
|
+
it('maps a legacy inline blob (file_id / mime_type) written before the cutover', async () => {
|
|
2327
|
+
const engine = makeFakeEngine();
|
|
2328
|
+
let n = 0;
|
|
2329
|
+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
|
|
2330
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2331
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
2332
|
+
const row = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
|
|
2333
|
+
row.attachments = [
|
|
2334
|
+
{ file_id: 'file_b', name: 'old.pdf', mime_type: 'application/pdf', size: 12, url: 'https://cdn/old.pdf' },
|
|
2335
|
+
];
|
|
2336
|
+
const acts = await svc.listActions(req.id, SYS);
|
|
2337
|
+
expect(acts.find(a => a.action === 'approve')?.attachments).toEqual([
|
|
2338
|
+
{ id: 'file_b', name: 'old.pdf', mimeType: 'application/pdf', size: 12, url: 'https://cdn/old.pdf' },
|
|
2339
|
+
]);
|
|
2340
|
+
});
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
// #3508: `queue` is declared-but-unenforced — resolveApproverSpec has no queue
|
|
2344
|
+
// branch, so the spec value falls through to the dead `queue:<id>` literal.
|
|
2345
|
+
// The engine must at least WARN so operators can see the silent dead slot;
|
|
2346
|
+
// the spec marks the type non-authorable so designers stop offering it.
|
|
2347
|
+
describe('ApprovalService — queue approver is unresolved (#3508)', () => {
|
|
2348
|
+
it('falls back to the dead literal and warns', async () => {
|
|
2349
|
+
const engine = makeFakeEngine();
|
|
2350
|
+
const warnings: any[] = [];
|
|
2351
|
+
let n = 0;
|
|
2352
|
+
const svc = new ApprovalService({
|
|
2353
|
+
engine: engine as any,
|
|
2354
|
+
clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
|
|
2355
|
+
logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) },
|
|
2356
|
+
});
|
|
2357
|
+
const req = await svc.openNodeRequest(
|
|
2358
|
+
{ ...openInput([]), config: { approvers: [{ type: 'queue', value: 'q_west' }], behavior: 'first_response', lockRecord: false } },
|
|
2359
|
+
CTX,
|
|
2360
|
+
);
|
|
2361
|
+
// No queue expansion exists: the slot is the raw `type:value` literal,
|
|
2362
|
+
// which matches no real user id — the request routes to nobody.
|
|
2363
|
+
expect(req.pending_approvers).toEqual(['queue:q_west']);
|
|
2364
|
+
expect(warnings.some(([msg]) => String(msg).includes("'queue'") && String(msg).includes('#3508'))).toBe(true);
|
|
2365
|
+
});
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2368
|
+
// #3807 follow-up: `queue` was not the only way to end up with a slot nobody
|
|
2369
|
+
// can act on — every GRAPH approver type falls back to the same literal when
|
|
2370
|
+
// its lookup finds no one, and that fallback used to happen in total silence.
|
|
2371
|
+
// A stuck approval was the first symptom; the log said nothing. The literal
|
|
2372
|
+
// stays (15.x slots and substring fixtures depend on it) — it just announces
|
|
2373
|
+
// itself now.
|
|
2374
|
+
describe('ApprovalService — a graph approver that expands to nobody warns (#3807)', () => {
|
|
2375
|
+
const svcWithWarnings = (engine: any) => {
|
|
2376
|
+
const warnings: any[] = [];
|
|
2377
|
+
let n = 0;
|
|
2378
|
+
const svc = new ApprovalService({
|
|
2379
|
+
engine,
|
|
2380
|
+
clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
|
|
2381
|
+
logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) },
|
|
2382
|
+
});
|
|
2383
|
+
return { svc, warnings };
|
|
2384
|
+
};
|
|
2385
|
+
|
|
2386
|
+
const approverInput = (type: string, value: string) => ({
|
|
2387
|
+
...openInput([]),
|
|
2388
|
+
config: { approvers: [{ type, value }], behavior: 'first_response' as const, lockRecord: false },
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
it.each([
|
|
2392
|
+
['team', 'team_gone'],
|
|
2393
|
+
['department', 'bu_gone'],
|
|
2394
|
+
['position', 'nobody_holds_this'],
|
|
2395
|
+
['org_membership_level', 'member'],
|
|
2396
|
+
])('%s: the dead literal is logged with its type, value and org', async (type, value) => {
|
|
2397
|
+
const engine = makeFakeEngine();
|
|
2398
|
+
const { svc, warnings } = svcWithWarnings(engine);
|
|
2399
|
+
const req = await svc.openNodeRequest(approverInput(type, value), CTX);
|
|
2400
|
+
|
|
2401
|
+
expect(req.pending_approvers).toEqual([`${type}:${value}`]);
|
|
2402
|
+
const hit = warnings.find(([msg]) => String(msg).includes('expanded to nobody'));
|
|
2403
|
+
expect(hit, `no warning for ${type}`).toBeTruthy();
|
|
2404
|
+
expect(String(hit[0])).toContain('#3807');
|
|
2405
|
+
expect(hit[1]).toMatchObject({ type, value, organizationId: 't1' });
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
it('stays quiet when the graph DOES resolve someone', async () => {
|
|
2409
|
+
const engine = makeFakeEngine();
|
|
2410
|
+
engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_ok', user_id: 'u5' }];
|
|
2411
|
+
const { svc, warnings } = svcWithWarnings(engine);
|
|
2412
|
+
const req = await svc.openNodeRequest(approverInput('team', 'team_ok'), CTX);
|
|
2413
|
+
|
|
2414
|
+
expect(req.pending_approvers).toEqual(['u5']);
|
|
2415
|
+
expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]);
|
|
2416
|
+
});
|
|
2417
|
+
|
|
2418
|
+
it('stays quiet for `user` — a literal id was never a lookup that could come back empty', async () => {
|
|
2419
|
+
const engine = makeFakeEngine();
|
|
2420
|
+
const { svc, warnings } = svcWithWarnings(engine);
|
|
2421
|
+
const req = await svc.openNodeRequest(approverInput('user', 'u_unknown'), CTX);
|
|
2422
|
+
|
|
2423
|
+
expect(req.pending_approvers).toEqual(['u_unknown']);
|
|
2424
|
+
expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]);
|
|
2425
|
+
});
|
|
2426
|
+
});
|
|
2427
|
+
|
|
2428
|
+
// ── File-access delegate (ADR-0104 D3 wave 2) ────────────────────────
|
|
2429
|
+
//
|
|
2430
|
+
// A decision attachment is OWNED by its `sys_approval_action` row, so the
|
|
2431
|
+
// storage service would otherwise authorize the download by testing whether
|
|
2432
|
+
// the caller can READ that row. It cannot — the table is closed to ordinary
|
|
2433
|
+
// approver positions — which denied the very approver the attachment was filed
|
|
2434
|
+
// for (reproduced in the browser against app-showcase). `sys_approval_action`
|
|
2435
|
+
// therefore declares `fileAccessDelegate: 'approvals'` and the service answers,
|
|
2436
|
+
// reusing the rule that already governs seeing a decision: visibility of the
|
|
2437
|
+
// PARENT REQUEST, exactly as listActions applies it.
|
|
2438
|
+
describe('ApprovalService — authorizeFileRead delegate (ADR-0104 D3 wave 2)', () => {
|
|
2439
|
+
const svcFor = (engine: any) => {
|
|
2440
|
+
let n = 0;
|
|
2441
|
+
return new ApprovalService({
|
|
2442
|
+
engine,
|
|
2443
|
+
clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
|
|
2444
|
+
});
|
|
2445
|
+
};
|
|
2446
|
+
|
|
2447
|
+
const seedDecision = async (engine: any) => {
|
|
2448
|
+
const svc = svcFor(engine);
|
|
2449
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2450
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
|
|
2451
|
+
const action = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
|
|
2452
|
+
return { svc, req, actionId: String(action.id) };
|
|
2453
|
+
};
|
|
2454
|
+
|
|
2455
|
+
it('allows a caller who can see the parent request', async () => {
|
|
2456
|
+
const engine = makeFakeEngine();
|
|
2457
|
+
const { svc, actionId } = await seedDecision(engine);
|
|
2458
|
+
|
|
2459
|
+
expect(await svc.authorizeFileRead(actionId, SYS)).toBe(true);
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
it('denies a caller who cannot see the parent request', async () => {
|
|
2463
|
+
const engine = makeFakeEngine();
|
|
2464
|
+
const { svc, actionId } = await seedDecision(engine);
|
|
2465
|
+
// getRequest is the single gate this delegates to — when it yields nothing
|
|
2466
|
+
// for this caller, the bytes must be refused too.
|
|
2467
|
+
vi.spyOn(svc as any, 'getRequest').mockResolvedValue(null);
|
|
2468
|
+
|
|
2469
|
+
expect(await svc.authorizeFileRead(actionId, CTX)).toBe(false);
|
|
2470
|
+
});
|
|
2471
|
+
|
|
2472
|
+
it('denies an unknown action id', async () => {
|
|
2473
|
+
const engine = makeFakeEngine();
|
|
2474
|
+
const { svc } = await seedDecision(engine);
|
|
2475
|
+
|
|
2476
|
+
expect(await svc.authorizeFileRead('aact_does_not_exist', SYS)).toBe(false);
|
|
2477
|
+
expect(await svc.authorizeFileRead('', SYS)).toBe(false);
|
|
2478
|
+
});
|
|
2479
|
+
|
|
2480
|
+
it('fails CLOSED when the lookup throws', async () => {
|
|
2481
|
+
const engine = makeFakeEngine();
|
|
2482
|
+
const { svc, actionId } = await seedDecision(engine);
|
|
2483
|
+
vi.spyOn(engine as any, 'find').mockRejectedValue(new Error('driver down'));
|
|
2484
|
+
|
|
2485
|
+
expect(await svc.authorizeFileRead(actionId, SYS)).toBe(false);
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
it('sys_approval_action declares the delegate, so the storage gate asks the service', async () => {
|
|
2489
|
+
const { SysApprovalAction } = await import('./sys-approval-action.object.js');
|
|
2490
|
+
expect((SysApprovalAction as any).fileAccessDelegate).toBe('approvals');
|
|
2491
|
+
});
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2494
|
+
// ── Participant visibility (#3590) ───────────────────────────────────
|
|
2495
|
+
//
|
|
2496
|
+
// getRequest/listRequests deliberately query with SYSTEM_CTX (the
|
|
2497
|
+
// approver-visibility rule spans identity forms RLS cannot model), but only
|
|
2498
|
+
// the TENANT half of that rule was ever applied — so any authenticated user
|
|
2499
|
+
// could read any request in their tenant, and after #3580 its decision
|
|
2500
|
+
// attachments too. These lock in the participant half.
|
|
2501
|
+
describe('ApprovalService — participant visibility (#3590)', () => {
|
|
2502
|
+
const svcFor = (engine: any) => {
|
|
2503
|
+
let n = 0;
|
|
2504
|
+
return new ApprovalService({ engine, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
|
|
2505
|
+
};
|
|
2506
|
+
const asUser = (userId: string) => ({ userId, tenantId: 't1', positions: [], permissions: [] } as any);
|
|
2507
|
+
const ADMIN = { userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'] } as any;
|
|
2508
|
+
|
|
2509
|
+
it('the submitter and a pending approver can read it; a same-tenant stranger cannot', async () => {
|
|
2510
|
+
const engine = makeFakeEngine();
|
|
2511
|
+
const svc = svcFor(engine);
|
|
2512
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
|
|
2513
|
+
|
|
2514
|
+
expect(await svc.getRequest(req.id, asUser('u1'))).not.toBeNull();
|
|
2515
|
+
expect(await svc.getRequest(req.id, asUser('u9'))).not.toBeNull();
|
|
2516
|
+
expect(await svc.getRequest(req.id, asUser('u_stranger'))).toBeNull();
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2519
|
+
it('someone who already acted keeps access after their slot moves on', async () => {
|
|
2520
|
+
const engine = makeFakeEngine();
|
|
2521
|
+
const svc = svcFor(engine);
|
|
2522
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2523
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
2524
|
+
|
|
2525
|
+
// u9 is no longer a pending approver, but the decision is theirs — the
|
|
2526
|
+
// audit trail (and its attachments) must not vanish from under them.
|
|
2527
|
+
expect(await svc.getRequest(req.id, asUser('u9'))).not.toBeNull();
|
|
2528
|
+
});
|
|
2529
|
+
|
|
2530
|
+
it('an override admin keeps the unrestricted view the console depends on', async () => {
|
|
2531
|
+
const engine = makeFakeEngine();
|
|
2532
|
+
const svc = svcFor(engine);
|
|
2533
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2534
|
+
|
|
2535
|
+
expect(await svc.getRequest(req.id, ADMIN)).not.toBeNull();
|
|
2536
|
+
});
|
|
2537
|
+
|
|
2538
|
+
it('a tokenless context sees nothing — the gate fails closed', async () => {
|
|
2539
|
+
const engine = makeFakeEngine();
|
|
2540
|
+
const svc = svcFor(engine);
|
|
2541
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2542
|
+
|
|
2543
|
+
expect(await svc.getRequest(req.id, { tenantId: 't1', positions: [], permissions: [] } as any)).toBeNull();
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2546
|
+
it('listRequests no longer returns the whole tenant when no approverId filter is passed', async () => {
|
|
2547
|
+
const engine = makeFakeEngine();
|
|
2548
|
+
const svc = svcFor(engine);
|
|
2549
|
+
const mine = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1
|
|
2550
|
+
await svc.openNodeRequest(openInput(['u7'], { recordId: 'opp2', record: { id: 'opp2' } }), asUser('u_other')); // unrelated to u1
|
|
2551
|
+
|
|
2552
|
+
// The old behaviour: omit approverId and receive every request in the
|
|
2553
|
+
// tenant. `approverId` is a filter, never authorization.
|
|
2554
|
+
const seen = await svc.listRequests(undefined, asUser('u1'));
|
|
2555
|
+
expect(seen.map(r => r.id)).toEqual([mine.id]);
|
|
2556
|
+
|
|
2557
|
+
const strangerSees = await svc.listRequests(undefined, asUser('u_stranger'));
|
|
2558
|
+
expect(strangerSees).toEqual([]);
|
|
2559
|
+
|
|
2560
|
+
// The admin console still sees everything.
|
|
2561
|
+
expect((await svc.listRequests(undefined, ADMIN)).length).toBeGreaterThanOrEqual(2);
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
it('countRequests agrees with the list it paginates', async () => {
|
|
2565
|
+
const engine = makeFakeEngine();
|
|
2566
|
+
const svc = svcFor(engine);
|
|
2567
|
+
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2568
|
+
await svc.openNodeRequest(openInput(['u7'], { recordId: 'opp2', record: { id: 'opp2' } }), asUser('u_other'));
|
|
2569
|
+
|
|
2570
|
+
expect(await svc.countRequests(undefined, asUser('u_stranger'))).toBe(0);
|
|
2571
|
+
expect(await svc.countRequests(undefined, asUser('u1'))).toBe(1);
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
it('a write path still echoes back its own result to the user who made it', async () => {
|
|
2575
|
+
const engine = makeFakeEngine();
|
|
2576
|
+
const svc = svcFor(engine);
|
|
2577
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2578
|
+
|
|
2579
|
+
// Approving CLEARS `pending_approvers`, so the approver stops being a
|
|
2580
|
+
// participant the instant their own write lands. The operation authorized
|
|
2581
|
+
// itself; re-gating the echo would turn a successful write into a null
|
|
2582
|
+
// result for the very person who made it.
|
|
2583
|
+
const res = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, asUser('u9'));
|
|
2584
|
+
expect(res.request).not.toBeNull();
|
|
2585
|
+
expect(res.request.status).toBe('approved');
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
it('a service-to-service write echoes back too (no session at all)', async () => {
|
|
2589
|
+
const engine = makeFakeEngine();
|
|
2590
|
+
const svc = svcFor(engine);
|
|
2591
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2592
|
+
|
|
2593
|
+
// Flow-driven resumes and the SLA sweep carry no user. Since #3800 that is
|
|
2594
|
+
// expressible only as a SYSTEM context — a user-less non-system caller can
|
|
2595
|
+
// no longer act by naming an approver.
|
|
2596
|
+
const res = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
2597
|
+
expect(res.request).not.toBeNull();
|
|
2598
|
+
expect(res.request.status).toBe('approved');
|
|
2599
|
+
});
|
|
2600
|
+
});
|
|
2601
|
+
|
|
2602
|
+
// ── The ordering invariant the dead-run sweep rests on (#3456) ─────────
|
|
2603
|
+
//
|
|
2604
|
+
// `releaseDeadRunRequests` recalls a PENDING request whose owning run has
|
|
2605
|
+
// reached a TERMINAL state, on the premise that such a pair can only be an
|
|
2606
|
+
// orphan. That premise is not self-evident — it holds only because every
|
|
2607
|
+
// in-band transition moves the request OUT of `pending` before it hands the run
|
|
2608
|
+
// back. Resume first and a run that finishes promptly afterwards would be
|
|
2609
|
+
// indistinguishable from an orphan, so the sweep would cancel a LIVE approval —
|
|
2610
|
+
// precisely the one failure mode it is built never to have.
|
|
2611
|
+
//
|
|
2612
|
+
// Nothing enforces that ordering: it is a convention spread across four public
|
|
2613
|
+
// methods and seven resume/cancelRun call sites, any of which a refactor could
|
|
2614
|
+
// reorder without a single existing test going red. So pin the invariant
|
|
2615
|
+
// itself rather than the call order of any one method — at the instant the run
|
|
2616
|
+
// is handed back, no request owned by that run may still be `pending`.
|
|
2617
|
+
describe('in-band transitions finalise before they resume (#3456 invariant)', () => {
|
|
2618
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
2619
|
+
let svc: ApprovalService;
|
|
2620
|
+
let n = 0;
|
|
2621
|
+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
2622
|
+
/** One entry per hand-back, with any still-pending requests owned by the run. */
|
|
2623
|
+
let handoffs: Array<{ hook: string; stillPending: string[] }>;
|
|
2624
|
+
|
|
2625
|
+
/** A flow whose approval node declares the `revise` out-edge send-back needs. */
|
|
2626
|
+
const REVISE_FLOW = {
|
|
2627
|
+
name: 'deal_approval',
|
|
2628
|
+
edges: [{ id: 'e_rev', source: 'approve_step', target: 'wait_revision', label: 'revise' }],
|
|
2629
|
+
};
|
|
2630
|
+
|
|
2631
|
+
function recordHandoff(hook: string) {
|
|
2632
|
+
const rows = (engine._tables['sys_approval_request'] ?? []) as any[];
|
|
2633
|
+
handoffs.push({
|
|
2634
|
+
hook,
|
|
2635
|
+
stillPending: rows
|
|
2636
|
+
.filter(r => String(r.flow_run_id ?? '') === 'run_1' && r.status === 'pending')
|
|
2637
|
+
.map(r => String(r.id)),
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/** Assert every hand-back this scenario made was clean. */
|
|
2642
|
+
function expectCleanHandoffs() {
|
|
2643
|
+
expect(
|
|
2644
|
+
handoffs.length,
|
|
2645
|
+
'the run was never handed back — this scenario did not exercise the invariant',
|
|
2646
|
+
).toBeGreaterThan(0);
|
|
2647
|
+
for (const h of handoffs) {
|
|
2648
|
+
expect(
|
|
2649
|
+
h.stillPending,
|
|
2650
|
+
`${h.hook}() handed run_1 back while it still owned a pending request — `
|
|
2651
|
+
+ 'the dead-run sweep would treat that as an orphan and cancel a live approval',
|
|
2652
|
+
).toEqual([]);
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
beforeEach(async () => {
|
|
2657
|
+
engine = makeFakeEngine();
|
|
2658
|
+
n = 0;
|
|
2659
|
+
handoffs = [];
|
|
2660
|
+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
|
|
2661
|
+
svc.attachAutomation({
|
|
2662
|
+
async resume() { recordHandoff('resume'); },
|
|
2663
|
+
async cancelRun() { recordHandoff('cancelRun'); },
|
|
2664
|
+
async getFlow() { return REVISE_FLOW; },
|
|
2665
|
+
} as any);
|
|
2666
|
+
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
2667
|
+
});
|
|
2668
|
+
|
|
2669
|
+
const open = (configExtra: Record<string, any> = {}) =>
|
|
2670
|
+
svc.openNodeRequest(openInput(['u9'], {}, configExtra), CTX);
|
|
2671
|
+
|
|
2672
|
+
it('decide(approve) finalises before resuming', async () => {
|
|
2673
|
+
const req = await open();
|
|
2674
|
+
await svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
2675
|
+
expectCleanHandoffs();
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
it('decide(reject) finalises before resuming', async () => {
|
|
2679
|
+
const req = await open();
|
|
2680
|
+
await svc.decide(req.id, { decision: 'reject', actorId: 'u9' }, SYS);
|
|
2681
|
+
expectCleanHandoffs();
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
it('recall finalises before resuming', async () => {
|
|
2685
|
+
const req = await open();
|
|
2686
|
+
await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
2687
|
+
expectCleanHandoffs();
|
|
2688
|
+
});
|
|
2689
|
+
|
|
2690
|
+
it('sendBack finalises before resuming', async () => {
|
|
2691
|
+
const req = await open();
|
|
2692
|
+
await svc.sendBack(req.id, { actorId: 'u9', comment: 'fix the totals' }, asUser('u9'));
|
|
2693
|
+
expectCleanHandoffs();
|
|
2694
|
+
});
|
|
2695
|
+
|
|
2696
|
+
it('sendBack past the revision budget auto-rejects before resuming', async () => {
|
|
2697
|
+
// `maxRevisions: 0` takes the ADR-0044 loop-guard branch on the first
|
|
2698
|
+
// send-back — a separate resume site from the normal path above.
|
|
2699
|
+
const req = await open({ maxRevisions: 0 });
|
|
2700
|
+
const out = await svc.sendBack(req.id, { actorId: 'u9' }, asUser('u9'));
|
|
2701
|
+
expect(out.autoRejected, 'expected the auto-reject branch').toBe(true);
|
|
2702
|
+
expectCleanHandoffs();
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
it('recall inside the revise window cancels the run without a pending request', async () => {
|
|
2706
|
+
const req = await open();
|
|
2707
|
+
await svc.sendBack(req.id, { actorId: 'u9' }, asUser('u9'));
|
|
2708
|
+
handoffs = []; // isolate the recall's own hand-back
|
|
2709
|
+
await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
2710
|
+
expect(handoffs.map(h => h.hook)).toContain('cancelRun');
|
|
2711
|
+
expectCleanHandoffs();
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
it('resubmit re-enters the node without leaving the old request pending', async () => {
|
|
2715
|
+
const req = await open();
|
|
2716
|
+
await svc.sendBack(req.id, { actorId: 'u9' }, asUser('u9'));
|
|
2717
|
+
handoffs = []; // isolate the resubmit's own hand-back
|
|
2718
|
+
await svc.resubmit(req.id, { actorId: 'u1' }, CTX);
|
|
2719
|
+
expectCleanHandoffs();
|
|
2720
|
+
});
|
|
2721
|
+
});
|
|
2722
|
+
|
|
2723
|
+
/**
|
|
2724
|
+
* #3783 — the status mirror names the human who caused the transition.
|
|
2725
|
+
*
|
|
2726
|
+
* The mirror write lands on the CUSTOMER's object, so it is what fires that
|
|
2727
|
+
* object's record-change flows. It has to stay `isSystem` (the record is locked
|
|
2728
|
+
* while its approval is live), but dropping the actor left every one of those
|
|
2729
|
+
* cascades with no trigger user — which #3760 now refuses outright, forcing
|
|
2730
|
+
* "when the invoice is approved, do X" to declare `runAs:'system'`.
|
|
2731
|
+
*
|
|
2732
|
+
* Each case therefore asserts BOTH halves: the elevation survives (or the lock
|
|
2733
|
+
* hook stops mirroring at all) and the identity is present.
|
|
2734
|
+
*/
|
|
2735
|
+
describe('status mirror identity (#3783)', () => {
|
|
2736
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
2737
|
+
let svc: ApprovalService;
|
|
2738
|
+
let n = 0;
|
|
2739
|
+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
2740
|
+
|
|
2741
|
+
const REVISE_FLOW = {
|
|
2742
|
+
name: 'deal_approval',
|
|
2743
|
+
edges: [{ id: 'e_rev', source: 'approve_step', target: 'wait_revision', label: 'revise' }],
|
|
2744
|
+
};
|
|
2745
|
+
|
|
2746
|
+
/** The context the service presented on the mirror write, or undefined. */
|
|
2747
|
+
const mirrorContext = () =>
|
|
2748
|
+
engine._writes.filter(w => w.object === 'opportunity').at(-1)?.context as any;
|
|
2749
|
+
|
|
2750
|
+
const open = (configExtra: Record<string, any> = {}, ctx: any = CTX) =>
|
|
2751
|
+
svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status', ...configExtra }), ctx);
|
|
2752
|
+
|
|
2753
|
+
beforeEach(() => {
|
|
2754
|
+
engine = makeFakeEngine();
|
|
2755
|
+
n = 0;
|
|
2756
|
+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
|
|
2757
|
+
svc.attachAutomation({
|
|
2758
|
+
async resume() {},
|
|
2759
|
+
async cancelRun() {},
|
|
2760
|
+
async getFlow() { return REVISE_FLOW; },
|
|
2761
|
+
} as any);
|
|
2762
|
+
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
2763
|
+
});
|
|
2764
|
+
|
|
2765
|
+
it('submit: mirrors as the submitter, still elevated', async () => {
|
|
2766
|
+
await open();
|
|
2767
|
+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u1' });
|
|
2768
|
+
});
|
|
2769
|
+
|
|
2770
|
+
it('decide: mirrors as the deciding user', async () => {
|
|
2771
|
+
const req = await open();
|
|
2772
|
+
const approver = { ...CTX, userId: 'u9' };
|
|
2773
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, approver as any);
|
|
2774
|
+
expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
|
|
2775
|
+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
it('recall: mirrors as the recalling user', async () => {
|
|
2779
|
+
const req = await open();
|
|
2780
|
+
await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
2781
|
+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u1' });
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2784
|
+
it('sendBack: mirrors as the approver who returned it', async () => {
|
|
2785
|
+
const req = await open();
|
|
2786
|
+
const approver = { ...CTX, userId: 'u9' };
|
|
2787
|
+
await svc.sendBack(req.id, { actorId: 'u9', comment: 'redo the totals' }, approver as any);
|
|
2788
|
+
expect(engine._tables['opportunity'][0].approval_status).toBe('returned');
|
|
2789
|
+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
|
|
2790
|
+
});
|
|
2791
|
+
|
|
2792
|
+
it('sendBack past the revision budget: the auto-reject mirror names the approver too', async () => {
|
|
2793
|
+
const req = await open({ maxRevisions: 0 });
|
|
2794
|
+
const approver = { ...CTX, userId: 'u9' };
|
|
2795
|
+
const out = await svc.sendBack(req.id, { actorId: 'u9' }, approver as any);
|
|
2796
|
+
expect(out.autoRejected, 'expected the auto-reject branch').toBe(true);
|
|
2797
|
+
expect(engine._tables['opportunity'][0].approval_status).toBe('rejected');
|
|
2798
|
+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2801
|
+
it('action link: mirrors as the approver the token is bound to', async () => {
|
|
2802
|
+
// ADR-0043 email approval — no session at all, but the single-use hashed
|
|
2803
|
+
// token names exactly one approver, and `resolveActionToken` has just
|
|
2804
|
+
// re-checked they still hold a pending slot. That IS an authenticated act.
|
|
2805
|
+
const req = await open();
|
|
2806
|
+
const { approve } = await svc.issueActionTokens(req.id, 'u9');
|
|
2807
|
+
expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: true });
|
|
2808
|
+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
|
|
2809
|
+
});
|
|
2810
|
+
|
|
2811
|
+
it('never takes the identity from the caller-supplied actorId', async () => {
|
|
2812
|
+
// `actorId` arrives in the REST body (`body.actorId ?? context.userId`).
|
|
2813
|
+
// #3783 kept it out of the mirror identity; #3800 then stopped it from
|
|
2814
|
+
// reaching the slate check too, so borrowing a slot holder's identity now
|
|
2815
|
+
// fails outright rather than merely mislabelling the write. The mirror is
|
|
2816
|
+
// the second line here, not the first — assert both, so neither can regress
|
|
2817
|
+
// silently behind the other.
|
|
2818
|
+
const req = await open();
|
|
2819
|
+
const someoneElse = { ...CTX, userId: 'intruder' };
|
|
2820
|
+
await expect(
|
|
2821
|
+
svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, someoneElse as any),
|
|
2822
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
2823
|
+
expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
|
|
2824
|
+
expect(mirrorContext()?.userId).not.toBe('u9');
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
it('SLA auto-decision: stays user-less — no human did it', async () => {
|
|
2828
|
+
const req = await open({ escalation: { timeoutHours: 1, action: 'auto_approve', notifySubmitter: false } });
|
|
2829
|
+
const raw = engine._tables['sys_approval_request'].find((r: any) => r.id === req.id)!;
|
|
2830
|
+
raw.created_at = new Date(baseTime - 3 * 60 * 60 * 1000).toISOString();
|
|
2831
|
+
await svc.runEscalations();
|
|
2832
|
+
expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
|
|
2833
|
+
// `system:sla` is a reserved audit actor, not a user — it must never be
|
|
2834
|
+
// presented as one. The cascade stays user-less on purpose; a flow that
|
|
2835
|
+
// wants to react to an SLA auto-decision declares runAs:'system'.
|
|
2836
|
+
expect(mirrorContext()?.userId).toBeUndefined();
|
|
2837
|
+
expect(mirrorContext()).toMatchObject({ isSystem: true });
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
it('dead-run sweep: stays user-less — no human did it', async () => {
|
|
2841
|
+
await open();
|
|
2842
|
+
svc.attachAutomation({ getRun: async () => ({ status: 'failed' }) } as any);
|
|
2843
|
+
expect(await svc.releaseDeadRunRequests()).toMatchObject({ released: 1 });
|
|
2844
|
+
expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
|
|
2845
|
+
expect(mirrorContext()?.userId).toBeUndefined();
|
|
2846
|
+
expect(mirrorContext()).toMatchObject({ isSystem: true });
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2849
|
+
it('carries the actor WITHOUT org-scoping the write', async () => {
|
|
2850
|
+
// `tenantId` on an ExecutionContext is a driver-scoping knob, not
|
|
2851
|
+
// attribution: ObjectQL turns it into a tenant predicate on the update. The
|
|
2852
|
+
// submitter's org (`t1` on CTX) must therefore not ride along, or the mirror
|
|
2853
|
+
// would silently no-op on a record whose org differs from the request's.
|
|
2854
|
+
await open();
|
|
2855
|
+
expect(mirrorContext()).not.toHaveProperty('tenantId');
|
|
2856
|
+
expect(mirrorContext()).not.toHaveProperty('organizationId');
|
|
1489
2857
|
});
|
|
1490
2858
|
});
|