@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.2
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/CHANGELOG.md +863 -0
- package/dist/index.d.mts +2236 -2688
- package/dist/index.d.ts +2236 -2688
- package/dist/index.js +591 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +590 -127
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -10
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -38
- package/src/action-link-pages.ts +0 -102
- package/src/approval-actor-impersonation.test.ts +0 -330
- package/src/approval-node.test.ts +0 -356
- package/src/approval-node.ts +0 -196
- package/src/approval-revise.test.ts +0 -418
- package/src/approval-service.test.ts +0 -2858
- package/src/approval-service.ts +0 -3617
- package/src/approvals-plugin.ts +0 -294
- package/src/approver-cross-org.integration.test.ts +0 -206
- package/src/approver-org-scope.test.ts +0 -201
- package/src/approver-org-scope.ts +0 -261
- package/src/index.ts +0 -42
- package/src/lifecycle-hooks.ts +0 -201
- package/src/nav-contribution.test.ts +0 -50
- package/src/record-lock-schedule-run.integration.test.ts +0 -206
- package/src/status-mirror-cascade.integration.test.ts +0 -224
- package/src/sys-approval-action.object.ts +0 -149
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.test.ts +0 -42
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -116
- package/src/sys-approval-request.object.ts +0 -413
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/bundle-ownership.test.ts +0 -48
- package/src/translations/en.objects.generated.ts +0 -311
- package/src/translations/es-ES.objects.generated.ts +0 -311
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -311
- package/src/translations/zh-CN.objects.generated.ts +0 -311
- package/tsconfig.json +0 -10
|
@@ -1,2858 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Node-era approval service tests (ADR-0019).
|
|
5
|
-
*
|
|
6
|
-
* Approval is a flow node — there is no standalone process engine. These tests
|
|
7
|
-
* exercise the service directly: opening a node-driven request, recording
|
|
8
|
-
* decisions (first_response / unanimous), the public `decide()` resume bridge,
|
|
9
|
-
* the read API, and the global record-lock hook.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
13
|
-
import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
|
|
14
|
-
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
|
|
15
|
-
|
|
16
|
-
interface FakeRow { [k: string]: any }
|
|
17
|
-
|
|
18
|
-
function makeFakeEngine() {
|
|
19
|
-
const tables: Record<string, FakeRow[]> = {};
|
|
20
|
-
const ensure = (n: string) => (tables[n] ??= []);
|
|
21
|
-
const hooks: Record<string, Array<{ handler: (ctx: any) => any | Promise<any>; object?: string | string[]; packageId?: string }>> = {};
|
|
22
|
-
|
|
23
|
-
function matches(row: FakeRow, filter: any): boolean {
|
|
24
|
-
if (!filter || typeof filter !== 'object') return true;
|
|
25
|
-
for (const [k, v] of Object.entries(filter)) {
|
|
26
|
-
if (k === '$or') {
|
|
27
|
-
if (!(v as any[]).some(sub => matches(row, sub))) return false;
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
const rv = row[k];
|
|
31
|
-
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
|
|
32
|
-
if (!(v as any).$in.includes(rv)) return false;
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
|
|
36
|
-
if (rv === (v as any).$ne) return false;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
if (v != null && typeof v === 'object' && '$contains' in (v as any)) {
|
|
40
|
-
if (!String(rv ?? '').includes(String((v as any).$contains))) return false;
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
if (rv !== v) return false;
|
|
44
|
-
}
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Every `update` the service made, with the context it presented (#3783). */
|
|
49
|
-
const writes: Array<{ object: string; data: any; context: any }> = [];
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
_tables: tables,
|
|
53
|
-
_hooks: hooks,
|
|
54
|
-
_writes: writes,
|
|
55
|
-
async find(object: string, options?: any) {
|
|
56
|
-
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
|
|
57
|
-
if (options?.orderBy?.[0]) {
|
|
58
|
-
// Canonical SortNode key only (spec/data/query.zod.ts): a sloppy
|
|
59
|
-
// `direction:` key must fall through to the schema default (asc),
|
|
60
|
-
// exactly like the real engine — that's how the remind() cool-down
|
|
61
|
-
// regression stayed invisible when this mock honored both keys.
|
|
62
|
-
const { field, order } = options.orderBy[0];
|
|
63
|
-
rows.sort((a, b) => {
|
|
64
|
-
const av = a[field]; const bv = b[field];
|
|
65
|
-
if (av === bv) return 0;
|
|
66
|
-
const cmp = av > bv ? 1 : -1;
|
|
67
|
-
return order === 'desc' ? -cmp : cmp;
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
const start = options?.offset ?? 0;
|
|
71
|
-
return rows.slice(start, start + (options?.limit ?? 1000));
|
|
72
|
-
},
|
|
73
|
-
async insert(object: string, data: any) {
|
|
74
|
-
ensure(object).push({ ...data });
|
|
75
|
-
return { ...data };
|
|
76
|
-
},
|
|
77
|
-
async update(object: string, idOrData: any, _opts?: any) {
|
|
78
|
-
const data = typeof idOrData === 'object' ? idOrData : _opts;
|
|
79
|
-
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
|
|
80
|
-
writes.push({ object, data, context: _opts?.context });
|
|
81
|
-
const table = ensure(object);
|
|
82
|
-
const i = table.findIndex(r => r.id === id);
|
|
83
|
-
if (i >= 0) table[i] = { ...table[i], ...data };
|
|
84
|
-
return table[i];
|
|
85
|
-
},
|
|
86
|
-
async delete(object: string, options?: any) {
|
|
87
|
-
const table = ensure(object);
|
|
88
|
-
const id = options?.where?.id ?? options?.id;
|
|
89
|
-
const i = table.findIndex(r => r.id === id);
|
|
90
|
-
if (i >= 0) table.splice(i, 1);
|
|
91
|
-
return { id };
|
|
92
|
-
},
|
|
93
|
-
// ── hook surface (for the record-lock hook) ──
|
|
94
|
-
registerHook(event: string, handler: (ctx: any) => any, options?: any) {
|
|
95
|
-
(hooks[event] ??= []).push({ handler, object: options?.object, packageId: options?.packageId });
|
|
96
|
-
},
|
|
97
|
-
unregisterHooksByPackage(packageId: string): number {
|
|
98
|
-
let n = 0;
|
|
99
|
-
for (const ev of Object.keys(hooks)) {
|
|
100
|
-
const before = hooks[ev].length;
|
|
101
|
-
hooks[ev] = hooks[ev].filter(h => h.packageId !== packageId);
|
|
102
|
-
n += before - hooks[ev].length;
|
|
103
|
-
}
|
|
104
|
-
return n;
|
|
105
|
-
},
|
|
106
|
-
async fire(event: string, ctx: any) {
|
|
107
|
-
for (const h of hooks[event] ?? []) {
|
|
108
|
-
if (h.object) {
|
|
109
|
-
const objs = Array.isArray(h.object) ? h.object : [h.object];
|
|
110
|
-
if (!objs.includes(ctx.object)) continue;
|
|
111
|
-
}
|
|
112
|
-
await h.handler(ctx);
|
|
113
|
-
}
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
119
|
-
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
|
|
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
|
-
|
|
130
|
-
function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
|
|
131
|
-
return {
|
|
132
|
-
approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
|
|
133
|
-
behavior: 'first_response' as const,
|
|
134
|
-
lockRecord: true,
|
|
135
|
-
...extra,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function openInput(approvers: string[], extra: Record<string, any> = {}, configExtra: Record<string, any> = {}) {
|
|
140
|
-
return {
|
|
141
|
-
object: 'opportunity',
|
|
142
|
-
recordId: 'opp1',
|
|
143
|
-
runId: 'run_1',
|
|
144
|
-
nodeId: 'approve_step',
|
|
145
|
-
flowName: 'deal_approval',
|
|
146
|
-
config: nodeConfig(approvers, configExtra),
|
|
147
|
-
record: { id: 'opp1', amount: 100 },
|
|
148
|
-
...extra,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
describe('ApprovalService (node era)', () => {
|
|
153
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
154
|
-
let svc: ApprovalService;
|
|
155
|
-
let n = 0;
|
|
156
|
-
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
157
|
-
|
|
158
|
-
beforeEach(() => {
|
|
159
|
-
engine = makeFakeEngine();
|
|
160
|
-
n = 0;
|
|
161
|
-
svc = new ApprovalService({
|
|
162
|
-
engine: engine as any,
|
|
163
|
-
clock: { now: () => new Date(baseTime + (n++) * 1000) },
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
// ── openNodeRequest ─────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
it('openNodeRequest: creates a pending request + submit action with flow correlation', async () => {
|
|
170
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
171
|
-
expect(req.status).toBe('pending');
|
|
172
|
-
expect(req.process_name).toBe('flow:deal_approval');
|
|
173
|
-
expect(req.flow_run_id).toBe('run_1');
|
|
174
|
-
expect(req.flow_node_id).toBe('approve_step');
|
|
175
|
-
expect(req.pending_approvers).toEqual(['u9']);
|
|
176
|
-
expect(engine._tables['sys_approval_request']).toHaveLength(1);
|
|
177
|
-
expect(engine._tables['sys_approval_action'][0].action).toBe('submit');
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('openNodeRequest: snapshots the node config on the row', async () => {
|
|
181
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
182
|
-
const raw = engine._tables['sys_approval_request'][0];
|
|
183
|
-
expect(JSON.parse(raw.node_config_json)).toMatchObject({ behavior: 'first_response', lockRecord: true });
|
|
184
|
-
});
|
|
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
|
-
|
|
220
|
-
it('openNodeRequest: deduplicates a pending request per (object, record)', async () => {
|
|
221
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
222
|
-
await expect(svc.openNodeRequest(openInput(['u9'], { runId: 'run_2' }), CTX))
|
|
223
|
-
.rejects.toThrow(/DUPLICATE_REQUEST/);
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it('openNodeRequest: requires object, recordId, runId', async () => {
|
|
227
|
-
await expect(svc.openNodeRequest(openInput(['u9'], { object: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
|
|
228
|
-
await expect(svc.openNodeRequest(openInput(['u9'], { recordId: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
|
|
229
|
-
await expect(svc.openNodeRequest(openInput(['u9'], { runId: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('openNodeRequest: mirrors status onto the business record when configured', async () => {
|
|
233
|
-
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
234
|
-
await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
235
|
-
expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
|
|
236
|
-
});
|
|
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
|
-
|
|
557
|
-
// ── approver expansion: position (ADR-0090 D3) ──────────────────
|
|
558
|
-
|
|
559
|
-
const positionInput = (extra: Record<string, any> = {}) => ({
|
|
560
|
-
...openInput([]),
|
|
561
|
-
config: {
|
|
562
|
-
approvers: [{ type: 'position' as const, value: 'sales_manager' }],
|
|
563
|
-
behavior: 'first_response' as const,
|
|
564
|
-
lockRecord: true,
|
|
565
|
-
},
|
|
566
|
-
...extra,
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
it('position approver: expands via sys_user_position, org-scoped', async () => {
|
|
570
|
-
engine._tables['sys_user_position'] = [
|
|
571
|
-
{ id: 'up1', user_id: 'u5', position: 'sales_manager', organization_id: 't1' },
|
|
572
|
-
{ id: 'up2', user_id: 'u6', position: 'sales_manager', organization_id: 't1' },
|
|
573
|
-
{ id: 'up3', user_id: 'u7', position: 'sales_manager', organization_id: 't2' }, // other tenant
|
|
574
|
-
{ id: 'up4', user_id: 'u8', position: 'cfo', organization_id: 't1' }, // other position
|
|
575
|
-
];
|
|
576
|
-
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
577
|
-
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it('position approver: unions the sys_member.role transition source (ADR-0057 D4)', async () => {
|
|
581
|
-
engine._tables['sys_user_position'] = [
|
|
582
|
-
{ id: 'up1', user_id: 'u5', position: 'sales_manager', organization_id: 't1' },
|
|
583
|
-
];
|
|
584
|
-
engine._tables['sys_member'] = [
|
|
585
|
-
{ id: 'm1', user_id: 'u6', role: 'sales_manager', organization_id: 't1' },
|
|
586
|
-
{ id: 'm2', user_id: 'u5', role: 'sales_manager', organization_id: 't1' }, // deduped
|
|
587
|
-
];
|
|
588
|
-
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
589
|
-
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it('position approver: falls back to a position: literal when nobody holds it', async () => {
|
|
593
|
-
const req = await svc.openNodeRequest(positionInput(), CTX);
|
|
594
|
-
expect(req.pending_approvers).toEqual(['position:sales_manager']);
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
// ── approver expansion: org_membership_level + its deprecated `role` alias
|
|
598
|
-
// (ADR-0090 D3) ────────────────────────────────────────────────────────
|
|
599
|
-
|
|
600
|
-
// `recordId` is parameterised: the service rejects a second pending request
|
|
601
|
-
// on the same record, and the alias test deliberately opens two.
|
|
602
|
-
const tierInput = (type: 'org_membership_level' | 'role', recordId = 'opp1') => ({
|
|
603
|
-
...openInput([]),
|
|
604
|
-
recordId,
|
|
605
|
-
record: { id: recordId, amount: 100 },
|
|
606
|
-
config: {
|
|
607
|
-
approvers: [{ type: type as any, value: 'admin' }],
|
|
608
|
-
behavior: 'first_response' as const,
|
|
609
|
-
lockRecord: true,
|
|
610
|
-
},
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
it('org_membership_level approver: expands the better-auth tier, org-scoped', async () => {
|
|
614
|
-
engine._tables['sys_member'] = [
|
|
615
|
-
{ id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
|
|
616
|
-
{ id: 'm2', user_id: 'u2', role: 'admin', organization_id: 't1' },
|
|
617
|
-
{ id: 'm3', user_id: 'u3', role: 'admin', organization_id: 't2' }, // other tenant
|
|
618
|
-
{ id: 'm4', user_id: 'u4', role: 'member', organization_id: 't1' }, // other tier
|
|
619
|
-
];
|
|
620
|
-
const req = await svc.openNodeRequest(tierInput('org_membership_level'), CTX);
|
|
621
|
-
expect(req.pending_approvers.sort()).toEqual(['u1', 'u2']);
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
it('deprecated `role` alias resolves IDENTICALLY to org_membership_level', async () => {
|
|
625
|
-
engine._tables['sys_member'] = [
|
|
626
|
-
{ id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
|
|
627
|
-
{ id: 'm2', user_id: 'u4', role: 'member', organization_id: 't1' },
|
|
628
|
-
];
|
|
629
|
-
const canonical = await svc.openNodeRequest(tierInput('org_membership_level', 'opp_canon'), CTX);
|
|
630
|
-
const deprecated = await svc.openNodeRequest(tierInput('role', 'opp_depr'), CTX);
|
|
631
|
-
expect(deprecated.pending_approvers).toEqual(canonical.pending_approvers);
|
|
632
|
-
expect(deprecated.pending_approvers).toEqual(['u1']);
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
// The fallback literal keeps the AUTHORED spelling: `sys_approval_approver`
|
|
636
|
-
// rows and `pending_approvers` slots written by 15.x carry `role:<v>`, and
|
|
637
|
-
// canonicalising the literal here would orphan every one of them.
|
|
638
|
-
it('deprecated `role` alias keeps its legacy literal on fallback (no orphaned slots)', async () => {
|
|
639
|
-
const req = await svc.openNodeRequest(tierInput('role'), CTX);
|
|
640
|
-
expect(req.pending_approvers).toEqual(['role:admin']);
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
it('org_membership_level falls back to its own canonical literal', async () => {
|
|
644
|
-
const req = await svc.openNodeRequest(tierInput('org_membership_level'), CTX);
|
|
645
|
-
expect(req.pending_approvers).toEqual(['org_membership_level:admin']);
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
it("department approver: honors the spec enum value 'department' (not just the business_unit dialect)", async () => {
|
|
649
|
-
engine._tables['sys_business_unit'] = [
|
|
650
|
-
{ id: 'bu1', organization_id: 't1', active: true },
|
|
651
|
-
{ id: 'bu2', parent_business_unit_id: 'bu1', organization_id: 't1', active: true },
|
|
652
|
-
];
|
|
653
|
-
engine._tables['sys_business_unit_member'] = [
|
|
654
|
-
{ id: 'bm1', business_unit_id: 'bu1', user_id: 'u5' },
|
|
655
|
-
{ id: 'bm2', business_unit_id: 'bu2', user_id: 'u6' },
|
|
656
|
-
];
|
|
657
|
-
const req = await svc.openNodeRequest(positionInput({
|
|
658
|
-
config: {
|
|
659
|
-
approvers: [{ type: 'department' as const, value: 'bu1' }],
|
|
660
|
-
behavior: 'first_response' as const,
|
|
661
|
-
lockRecord: true,
|
|
662
|
-
},
|
|
663
|
-
}), CTX);
|
|
664
|
-
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
|
|
665
|
-
});
|
|
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
|
-
|
|
730
|
-
// ── decideNode ──────────────────────────────────────────────────
|
|
731
|
-
|
|
732
|
-
it('decideNode: first_response approve finalizes immediately', async () => {
|
|
733
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
734
|
-
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
735
|
-
expect(out.finalized).toBe(true);
|
|
736
|
-
expect(out.decision).toBe('approve');
|
|
737
|
-
expect(out.runId).toBe('run_1');
|
|
738
|
-
expect(out.nodeId).toBe('approve_step');
|
|
739
|
-
expect(out.request.status).toBe('approved');
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
it('decideNode: reject finalizes as rejected', async () => {
|
|
743
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
744
|
-
const out = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u9', comment: 'no' }, SYS);
|
|
745
|
-
expect(out.finalized).toBe(true);
|
|
746
|
-
expect(out.request.status).toBe('rejected');
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
it('decideNode: unanimous holds until every approver acts', async () => {
|
|
750
|
-
const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
|
|
751
|
-
const first = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
752
|
-
expect(first.finalized).toBe(false);
|
|
753
|
-
expect(first.request.pending_approvers).toEqual(['u2']);
|
|
754
|
-
const second = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
|
|
755
|
-
expect(second.finalized).toBe(true);
|
|
756
|
-
expect(second.request.status).toBe('approved');
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
it('decideNode: blocks a non-approver in a non-system context', async () => {
|
|
760
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
761
|
-
await expect(
|
|
762
|
-
svc.decideNode(req.id, { decision: 'approve', actorId: 'mallory' }, { isSystem: false, positions: [], permissions: [] } as any),
|
|
763
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
it('decideNode: rejects a decision on a non-pending request', async () => {
|
|
767
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
768
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
769
|
-
await expect(svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS)).rejects.toThrow(/INVALID_STATE/);
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
it('decideNode: mirrors the terminal status onto the business record', async () => {
|
|
773
|
-
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
774
|
-
const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
775
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
776
|
-
expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
// ── decide(): public contract + resume bridge ───────────────────
|
|
780
|
-
|
|
781
|
-
it('decide: resumes the owning run down the matching branch on finalize', async () => {
|
|
782
|
-
const resumed: any[] = [];
|
|
783
|
-
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
784
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
785
|
-
const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
786
|
-
expect(out.finalized).toBe(true);
|
|
787
|
-
expect(out.resumed).toBe(true);
|
|
788
|
-
expect(out.runId).toBe('run_1');
|
|
789
|
-
expect(resumed).toHaveLength(1);
|
|
790
|
-
expect(resumed[0]).toMatchObject({ runId: 'run_1', signal: { branchLabel: 'approve' } });
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
it('decide: does not resume while a unanimous request is still pending', async () => {
|
|
794
|
-
const resumed: any[] = [];
|
|
795
|
-
svc.attachAutomation({ async resume(runId) { resumed.push(runId); } });
|
|
796
|
-
const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
|
|
797
|
-
const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
798
|
-
expect(out.finalized).toBe(false);
|
|
799
|
-
expect(out.resumed).toBe(false);
|
|
800
|
-
expect(resumed).toHaveLength(0);
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
it('decide: finalizes even when no automation is attached (resumed=false)', async () => {
|
|
804
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
805
|
-
const out = await svc.decide(req.id, { decision: 'reject', actorId: 'u9' }, SYS);
|
|
806
|
-
expect(out.finalized).toBe(true);
|
|
807
|
-
expect(out.resumed).toBe(false);
|
|
808
|
-
});
|
|
809
|
-
|
|
810
|
-
// ── read API ────────────────────────────────────────────────────
|
|
811
|
-
|
|
812
|
-
it('listRequests: filters by approver and status', async () => {
|
|
813
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
814
|
-
const pending = await svc.listRequests({ status: 'pending', approverId: 'u9' }, SYS);
|
|
815
|
-
expect(pending).toHaveLength(1);
|
|
816
|
-
const none = await svc.listRequests({ approverId: 'nobody' }, SYS);
|
|
817
|
-
expect(none).toHaveLength(0);
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
it('listRequests: approverId accepts a list and matches ANY identity', async () => {
|
|
821
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
822
|
-
// None of these identities individually except the last is the approver.
|
|
823
|
-
const hit = await svc.listRequests(
|
|
824
|
-
{ status: 'pending', approverId: ['someone-else', 'user@example.com', 'u9'] },
|
|
825
|
-
SYS,
|
|
826
|
-
);
|
|
827
|
-
expect(hit).toHaveLength(1);
|
|
828
|
-
// A list with no matching identity returns nothing.
|
|
829
|
-
const miss = await svc.listRequests({ approverId: ['a', 'b', 'role:viewer'] }, SYS);
|
|
830
|
-
expect(miss).toHaveLength(0);
|
|
831
|
-
// Empty / whitespace-only ids are ignored, not treated as a match-all.
|
|
832
|
-
const ignored = await svc.listRequests({ approverId: ['', ' '] }, SYS);
|
|
833
|
-
expect(ignored).toHaveLength(1);
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
it('listActions: returns the audit trail for a request', async () => {
|
|
837
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
838
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
839
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
840
|
-
expect(actions.map(a => a.action)).toEqual(['submit', 'approve']);
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
it('getRequest: returns null for an unknown id', async () => {
|
|
844
|
-
expect(await svc.getRequest('nope', SYS)).toBeNull();
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
// ── viewer capability (#3310) ───────────────────────────────────
|
|
848
|
-
it('getRequest: viewer.can_act is true for a pending approver, false for the submitter', async () => {
|
|
849
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
|
|
850
|
-
const asApprover = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
851
|
-
expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false, can_override: false });
|
|
852
|
-
const asSubmitter = await svc.getRequest(req.id, { userId: 'u1', tenantId: 't1' } as any);
|
|
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();
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
it('getRequest: viewer.can_act drops to false once the request is finalized', async () => {
|
|
861
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
862
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); // → approved
|
|
863
|
-
const after = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
864
|
-
expect(after!.status).toBe('approved');
|
|
865
|
-
expect(after!.viewer!.can_act).toBe(false);
|
|
866
|
-
});
|
|
867
|
-
|
|
868
|
-
it('listRequests: attaches viewer to every row from the caller context', async () => {
|
|
869
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
870
|
-
const rows = await svc.listRequests({ status: 'pending' }, { userId: 'u9', tenantId: 't1' } as any);
|
|
871
|
-
expect(rows.length).toBeGreaterThan(0);
|
|
872
|
-
expect(rows.every(r => r.viewer != null)).toBe(true);
|
|
873
|
-
expect(rows[0].viewer!.can_act).toBe(true);
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
// ── recall ──────────────────────────────────────────────────────
|
|
877
|
-
|
|
878
|
-
it('recall: submitter withdraws a pending request', async () => {
|
|
879
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
880
|
-
const out = await svc.recall(req.id, { actorId: 'u1', comment: 'changed my mind' }, CTX);
|
|
881
|
-
expect(out.request.status).toBe('recalled');
|
|
882
|
-
expect(out.request.completed_at).toBeTruthy();
|
|
883
|
-
expect(out.request.pending_approvers).toEqual([]);
|
|
884
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
885
|
-
expect(actions.map(a => a.action)).toEqual(['submit', 'recall']);
|
|
886
|
-
expect(actions[1].comment).toBe('changed my mind');
|
|
887
|
-
});
|
|
888
|
-
|
|
889
|
-
it('recall: blocks a non-submitter in a non-system context', async () => {
|
|
890
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
891
|
-
await expect(svc.recall(req.id, { actorId: 'u9' }, { positions: [], permissions: [] } as any))
|
|
892
|
-
.rejects.toThrow(/FORBIDDEN/);
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
it('recall: rejects a recall on a non-pending request', async () => {
|
|
896
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
897
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
898
|
-
await expect(svc.recall(req.id, { actorId: 'u1' }, SYS)).rejects.toThrow(/INVALID_STATE/);
|
|
899
|
-
});
|
|
900
|
-
|
|
901
|
-
it('recall: resumes the owning run down the reject branch with decision=recall', async () => {
|
|
902
|
-
const resumed: any[] = [];
|
|
903
|
-
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
904
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
905
|
-
const out = await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
906
|
-
expect(out.resumed).toBe(true);
|
|
907
|
-
expect(resumed[0]).toMatchObject({
|
|
908
|
-
runId: 'run_1',
|
|
909
|
-
signal: { branchLabel: 'reject', output: { decision: 'recall' } },
|
|
910
|
-
});
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
it('recall: mirrors `recalled` onto the business record when configured', async () => {
|
|
914
|
-
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
915
|
-
const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
916
|
-
await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
917
|
-
expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
|
|
918
|
-
});
|
|
919
|
-
|
|
920
|
-
// ── inbox display fields ────────────────────────────────────────
|
|
921
|
-
|
|
922
|
-
it('rows expose submitted_at as an alias of created_at', async () => {
|
|
923
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
924
|
-
expect(req.submitted_at).toBeTruthy();
|
|
925
|
-
expect(req.submitted_at).toBe(req.created_at);
|
|
926
|
-
const listed = await svc.listRequests({ status: 'pending' }, SYS);
|
|
927
|
-
expect(listed[0].submitted_at).toBe(listed[0].created_at);
|
|
928
|
-
});
|
|
929
|
-
|
|
930
|
-
it('rows carry authored flow/node labels when provided', async () => {
|
|
931
|
-
const req = await svc.openNodeRequest(
|
|
932
|
-
openInput(['u9'], { flowLabel: 'Deal Approval', nodeLabel: 'Manager Review' }), CTX,
|
|
933
|
-
);
|
|
934
|
-
expect(req.process_label).toBe('Deal Approval');
|
|
935
|
-
expect(req.step_label).toBe('Manager Review');
|
|
936
|
-
});
|
|
937
|
-
|
|
938
|
-
it('rows fall back to prettified machine names when labels are absent', async () => {
|
|
939
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
940
|
-
expect(req.process_label).toBe('Deal Approval'); // from `flow:deal_approval`
|
|
941
|
-
expect(req.step_label).toBe('Approve Step'); // from `approve_step`
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
it('listRequests enriches record_title and submitter_name', async () => {
|
|
945
|
-
engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', amount: 100 }];
|
|
946
|
-
engine._tables['sys_user'] = [{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }];
|
|
947
|
-
await svc.openNodeRequest(openInput(['u9']), CTX); // submitter_id = u1 (CTX.userId)
|
|
948
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
949
|
-
expect(rows[0].record_title).toBe('Acme Renewal');
|
|
950
|
-
expect(rows[0].submitter_name).toBe('Ada Lovelace');
|
|
951
|
-
});
|
|
952
|
-
|
|
953
|
-
it('enrichment falls back to the payload snapshot when the record is gone', async () => {
|
|
954
|
-
await svc.openNodeRequest(
|
|
955
|
-
openInput(['u9'], { record: { id: 'opp1', name: 'Snapshot Title', amount: 1 } }), CTX,
|
|
956
|
-
);
|
|
957
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
958
|
-
expect(rows[0].record_title).toBe('Snapshot Title');
|
|
959
|
-
});
|
|
960
|
-
|
|
961
|
-
it('enrichment resolves lookup foreign keys in the payload to record titles', async () => {
|
|
962
|
-
(engine as any).getSchema = (name: string) =>
|
|
963
|
-
name === 'opportunity'
|
|
964
|
-
? { label: 'Opportunity', fields: { name: {}, account: { type: 'lookup', reference: 'account' } } }
|
|
965
|
-
: name === 'account' ? { label: 'Account', fields: { name: {} } } : undefined;
|
|
966
|
-
engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', account: 'acc1' }];
|
|
967
|
-
engine._tables['account'] = [{ id: 'acc1', name: 'Acme Corp' }];
|
|
968
|
-
await svc.openNodeRequest(openInput(['u9'], { record: { id: 'opp1', name: 'Acme Renewal', account: 'acc1' } }), CTX);
|
|
969
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
970
|
-
expect(rows[0].object_label).toBe('Opportunity');
|
|
971
|
-
expect(rows[0].payload_display).toEqual({ account: 'Acme Corp' });
|
|
972
|
-
});
|
|
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
|
-
|
|
995
|
-
it('enrichment maps user-id approvers to display names', async () => {
|
|
996
|
-
engine._tables['sys_user'] = [{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }];
|
|
997
|
-
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
998
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
999
|
-
expect(rows[0].pending_approver_names).toEqual({ u9: 'Grace Hopper' });
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
it('listActions resolves actor display names', async () => {
|
|
1003
|
-
engine._tables['sys_user'] = [
|
|
1004
|
-
{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' },
|
|
1005
|
-
{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' },
|
|
1006
|
-
];
|
|
1007
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1008
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
1009
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
1010
|
-
expect(actions.map(a => (a as any).actor_name)).toEqual(['Ada Lovelace', 'Grace Hopper']);
|
|
1011
|
-
});
|
|
1012
|
-
|
|
1013
|
-
// ── thread interactions ─────────────────────────────────────────
|
|
1014
|
-
|
|
1015
|
-
it('reassign: hands the slot to a new approver and audits the move', async () => {
|
|
1016
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
|
|
1017
|
-
const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
|
|
1018
|
-
expect(out.request.pending_approvers).toEqual(['u7', 'u2']);
|
|
1019
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
1020
|
-
expect(actions.at(-1)).toMatchObject({ action: 'reassign', actor_id: 'u9', comment: 'u9 → u7' });
|
|
1021
|
-
});
|
|
1022
|
-
|
|
1023
|
-
it('reassign: notifies the new approver via messaging', async () => {
|
|
1024
|
-
const emitted: any[] = [];
|
|
1025
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
1026
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1027
|
-
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
|
|
1028
|
-
expect(emitted).toHaveLength(1);
|
|
1029
|
-
expect(emitted[0]).toMatchObject({ topic: 'approval.reassigned', audience: ['u7'] });
|
|
1030
|
-
});
|
|
1031
|
-
|
|
1032
|
-
it('reassign: blocks a non-holder and duplicate targets', async () => {
|
|
1033
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
|
|
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/);
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
it('remind: notifies pending approvers, audits, and throttles repeats', async () => {
|
|
1039
|
-
const emitted: any[] = [];
|
|
1040
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
1041
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
|
|
1042
|
-
const out = await svc.remind(req.id, { actorId: 'u1' }, CTX); // u1 = submitter (CTX.userId)
|
|
1043
|
-
expect(out.notified).toBe(2);
|
|
1044
|
-
// ADR-0043: per-approver fan-out so each reminder carries personal links.
|
|
1045
|
-
const reminders = emitted.filter(e => e.topic === 'approval.reminder');
|
|
1046
|
-
expect(reminders.map(r => r.audience)).toEqual([['u9'], ['u2']]);
|
|
1047
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
1048
|
-
expect(actions.at(-1)?.action).toBe('remind');
|
|
1049
|
-
// The fake clock steps 1s per call — well inside the 4h cool-down.
|
|
1050
|
-
await expect(svc.remind(req.id, { actorId: 'u1' }, CTX)).rejects.toThrow(/THROTTLED/);
|
|
1051
|
-
});
|
|
1052
|
-
|
|
1053
|
-
it('remind: cool-down measures from the NEWEST reminder, not the first', async () => {
|
|
1054
|
-
// Regression: the throttle query sorted with the non-canonical
|
|
1055
|
-
// `direction: 'desc'` key, which SortNode strips — so it sorted asc and
|
|
1056
|
-
// compared against the FIRST reminder ever sent. Once 4h passed after
|
|
1057
|
-
// reminder #1, every later remind() sailed through unthrottled.
|
|
1058
|
-
let nowMs = baseTime;
|
|
1059
|
-
const localSvc = new ApprovalService({
|
|
1060
|
-
engine: engine as any,
|
|
1061
|
-
clock: { now: () => new Date(nowMs += 1000) },
|
|
1062
|
-
});
|
|
1063
|
-
const req = await localSvc.openNodeRequest(openInput(['u9']), CTX);
|
|
1064
|
-
await localSvc.remind(req.id, { actorId: 'u1' }, CTX);
|
|
1065
|
-
// Jump past the cool-down: a second reminder is legitimately allowed.
|
|
1066
|
-
nowMs += REMIND_COOLDOWN_MS;
|
|
1067
|
-
await localSvc.remind(req.id, { actorId: 'u1' }, CTX);
|
|
1068
|
-
// Immediately after reminder #2 the throttle must bite again — with the
|
|
1069
|
-
// wrong sort key it compared against reminder #1 (now >4h old) and let
|
|
1070
|
-
// unlimited reminders through.
|
|
1071
|
-
await expect(localSvc.remind(req.id, { actorId: 'u1' }, CTX)).rejects.toThrow(/THROTTLED/);
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
it('remind: only the submitter may nudge', async () => {
|
|
1075
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1076
|
-
await expect(svc.remind(req.id, { actorId: 'u9' }, { positions: [], permissions: [] } as any))
|
|
1077
|
-
.rejects.toThrow(/FORBIDDEN/);
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
it('requestInfo: keeps the request pending and notifies the submitter', async () => {
|
|
1081
|
-
const emitted: any[] = [];
|
|
1082
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
1083
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1084
|
-
const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' }, asUser('u9'));
|
|
1085
|
-
expect(out.request.status).toBe('pending');
|
|
1086
|
-
expect(out.request.pending_approvers).toEqual(['u9']);
|
|
1087
|
-
expect(emitted[0]).toMatchObject({ topic: 'approval.request_info', audience: ['u1'] });
|
|
1088
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
1089
|
-
expect(actions.at(-1)).toMatchObject({ action: 'request_info', comment: 'Need the Q3 numbers' });
|
|
1090
|
-
});
|
|
1091
|
-
|
|
1092
|
-
it('comment: submitter and approver may reply; outsiders may not', async () => {
|
|
1093
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1094
|
-
await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX);
|
|
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')))
|
|
1097
|
-
.rejects.toThrow(/FORBIDDEN/);
|
|
1098
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
1099
|
-
expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
// ── actionable links (ADR-0043) ─────────────────────────────────
|
|
1103
|
-
|
|
1104
|
-
it('issueActionTokens: stores hashes only and binds approver + action', async () => {
|
|
1105
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1106
|
-
const tokens = await svc.issueActionTokens(req.id, 'u9');
|
|
1107
|
-
expect(tokens.approve).not.toBe(tokens.reject);
|
|
1108
|
-
const rows = engine._tables['sys_approval_token'];
|
|
1109
|
-
expect(rows).toHaveLength(2);
|
|
1110
|
-
expect(rows.every(r => r.token_hash.length === 64)).toBe(true); // sha256 hex, never the raw token
|
|
1111
|
-
expect(rows.every(r => !JSON.stringify(r).includes(tokens.approve))).toBe(true);
|
|
1112
|
-
await expect(svc.issueActionTokens(req.id, 'stranger')).rejects.toThrow(/FORBIDDEN/);
|
|
1113
|
-
});
|
|
1114
|
-
|
|
1115
|
-
it('redeem: approves as the bound approver and burns the token (single-use)', async () => {
|
|
1116
|
-
const resumed: any[] = [];
|
|
1117
|
-
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
1118
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1119
|
-
const { approve } = await svc.issueActionTokens(req.id, 'u9');
|
|
1120
|
-
const out = await svc.redeemActionToken(approve);
|
|
1121
|
-
expect(out).toMatchObject({ ok: true, action: 'approve', approverId: 'u9' });
|
|
1122
|
-
expect((out as any).request.status).toBe('approved');
|
|
1123
|
-
expect(resumed[0]?.signal?.branchLabel).toBe('approve');
|
|
1124
|
-
const acts = await svc.listActions(req.id, SYS);
|
|
1125
|
-
expect(acts.at(-1)).toMatchObject({ action: 'approve', actor_id: 'u9', comment: 'Via action link' });
|
|
1126
|
-
// replay
|
|
1127
|
-
expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: false, reason: 'consumed' });
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
it('peek: validates without consuming (GET never mutates)', async () => {
|
|
1131
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1132
|
-
const { reject } = await svc.issueActionTokens(req.id, 'u9');
|
|
1133
|
-
expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true, action: 'reject' });
|
|
1134
|
-
expect(await svc.peekActionToken(reject)).toMatchObject({ ok: true }); // still live
|
|
1135
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
1136
|
-
expect(fresh?.status).toBe('pending');
|
|
1137
|
-
});
|
|
1138
|
-
|
|
1139
|
-
it('redeem: dead tokens — invalid, expired, decided request, reassigned slot', async () => {
|
|
1140
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1141
|
-
expect(await svc.redeemActionToken('garbage')).toMatchObject({ ok: false, reason: 'invalid' });
|
|
1142
|
-
|
|
1143
|
-
const short = await svc.issueActionTokens(req.id, 'u9', { ttlMs: 1 });
|
|
1144
|
-
// fake clock advances 1s per call — far beyond a 1ms TTL
|
|
1145
|
-
expect(await svc.redeemActionToken(short.approve)).toMatchObject({ ok: false, reason: 'expired' });
|
|
1146
|
-
|
|
1147
|
-
const live = await svc.issueActionTokens(req.id, 'u9');
|
|
1148
|
-
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
|
|
1149
|
-
expect(await svc.redeemActionToken(live.approve)).toMatchObject({ ok: false, reason: 'not_approver' });
|
|
1150
|
-
|
|
1151
|
-
const forU7 = await svc.issueActionTokens(req.id, 'u7');
|
|
1152
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u7' }, SYS);
|
|
1153
|
-
expect(await svc.redeemActionToken(forU7.reject)).toMatchObject({ ok: false, reason: 'not_pending' });
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
|
-
it('remind: each concrete approver gets their own action links', async () => {
|
|
1157
|
-
const emitted: any[] = [];
|
|
1158
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
1159
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com']), CTX);
|
|
1160
|
-
await svc.remind(req.id, { actorId: 'u1' }, CTX);
|
|
1161
|
-
const reminders = emitted.filter(e => e.topic === 'approval.reminder');
|
|
1162
|
-
expect(reminders).toHaveLength(2);
|
|
1163
|
-
for (const r of reminders) {
|
|
1164
|
-
expect(r.audience).toHaveLength(1);
|
|
1165
|
-
expect(r.payload.actions).toHaveLength(2);
|
|
1166
|
-
expect(r.payload.actions[0].url).toContain('/api/v1/approvals/act?token=');
|
|
1167
|
-
}
|
|
1168
|
-
const urls = reminders.flatMap(r => r.payload.actions.map((a: any) => a.url));
|
|
1169
|
-
expect(new Set(urls).size).toBe(4); // every link is personal + per-action
|
|
1170
|
-
});
|
|
1171
|
-
|
|
1172
|
-
// ── pagination + search pushdown (#1745) ────────────────────────
|
|
1173
|
-
|
|
1174
|
-
async function openMany(n: number) {
|
|
1175
|
-
for (let i = 0; i < n; i++) {
|
|
1176
|
-
await svc.openNodeRequest(openInput(['u9'], {
|
|
1177
|
-
recordId: `opp${i}`, record: { id: `opp${i}`, name: `Deal ${i}` },
|
|
1178
|
-
}), CTX);
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
it('listRequests: windows pushable queries newest-first with limit/offset', async () => {
|
|
1183
|
-
await openMany(5);
|
|
1184
|
-
const page1 = await svc.listRequests({ limit: 2, offset: 0 }, SYS);
|
|
1185
|
-
const page2 = await svc.listRequests({ limit: 2, offset: 2 }, SYS);
|
|
1186
|
-
expect(page1.map(r => r.record_id)).toEqual(['opp4', 'opp3']); // created_at desc
|
|
1187
|
-
expect(page2.map(r => r.record_id)).toEqual(['opp2', 'opp1']);
|
|
1188
|
-
});
|
|
1189
|
-
|
|
1190
|
-
it('listRequests: q matches the payload snapshot (record titles) via pushdown', async () => {
|
|
1191
|
-
await openMany(3);
|
|
1192
|
-
const hit = await svc.listRequests({ q: 'Deal 1', limit: 10 }, SYS);
|
|
1193
|
-
expect(hit.map(r => r.record_id)).toEqual(['opp1']);
|
|
1194
|
-
const miss = await svc.listRequests({ q: 'no-such-thing', limit: 10 }, SYS);
|
|
1195
|
-
expect(miss).toHaveLength(0);
|
|
1196
|
-
});
|
|
1197
|
-
|
|
1198
|
-
it('countRequests: returns the unwindowed total for a filter', async () => {
|
|
1199
|
-
await openMany(4);
|
|
1200
|
-
expect(await svc.countRequests({ status: 'pending' }, SYS)).toBe(4);
|
|
1201
|
-
expect(await svc.countRequests({ q: 'Deal 2' }, SYS)).toBe(1);
|
|
1202
|
-
});
|
|
1203
|
-
|
|
1204
|
-
it('listRequests: approver queries resolve via the index and window engine-side', async () => {
|
|
1205
|
-
await openMany(4); // approver u9 on all
|
|
1206
|
-
await svc.openNodeRequest(openInput(['someone-else'], {
|
|
1207
|
-
recordId: 'oppX', record: { id: 'oppX', name: 'Other' },
|
|
1208
|
-
}), CTX);
|
|
1209
|
-
const page = await svc.listRequests({ approverId: 'u9', limit: 2, offset: 2 }, SYS);
|
|
1210
|
-
expect(page).toHaveLength(2);
|
|
1211
|
-
expect(page.every(r => r.pending_approvers?.includes('u9'))).toBe(true);
|
|
1212
|
-
expect(await svc.countRequests({ approverId: 'u9' }, SYS)).toBe(4);
|
|
1213
|
-
});
|
|
1214
|
-
|
|
1215
|
-
it('listRequests/countRequests: status arrays push down as $in', async () => {
|
|
1216
|
-
await openMany(3);
|
|
1217
|
-
const all = await svc.listRequests({ status: 'pending' }, SYS);
|
|
1218
|
-
await svc.decideNode(all[0].id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
1219
|
-
await svc.decideNode(all[1].id, { decision: 'reject', actorId: 'u9' }, SYS);
|
|
1220
|
-
const done = await svc.listRequests({ status: ['approved', 'rejected'] }, SYS);
|
|
1221
|
-
expect(done.map(r => r.status).sort()).toEqual(['approved', 'rejected']);
|
|
1222
|
-
expect(await svc.countRequests({ status: ['approved', 'rejected'] }, SYS)).toBe(2);
|
|
1223
|
-
expect(await svc.countRequests({ status: ['recalled'] }, SYS)).toBe(0);
|
|
1224
|
-
});
|
|
1225
|
-
|
|
1226
|
-
// ── pending-approver index (#1745 join table) ───────────────────
|
|
1227
|
-
|
|
1228
|
-
const indexRows = () => (engine._tables['sys_approval_approver'] ?? [])
|
|
1229
|
-
.map(r => ({ request_id: r.request_id, approver: r.approver }));
|
|
1230
|
-
|
|
1231
|
-
it('openNodeRequest mirrors every approver identity into the index', async () => {
|
|
1232
|
-
const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com', 'role:finance']), CTX);
|
|
1233
|
-
expect(indexRows()).toEqual([
|
|
1234
|
-
{ request_id: req.id, approver: 'u9' },
|
|
1235
|
-
{ request_id: req.id, approver: 'ada@example.com' },
|
|
1236
|
-
{ request_id: req.id, approver: 'role:finance' },
|
|
1237
|
-
]);
|
|
1238
|
-
});
|
|
1239
|
-
|
|
1240
|
-
it('decide and recall clear the request\'s index rows', async () => {
|
|
1241
|
-
const a = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1242
|
-
await svc.decideNode(a.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
1243
|
-
expect(indexRows()).toHaveLength(0);
|
|
1244
|
-
|
|
1245
|
-
const b = await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
|
|
1246
|
-
await svc.recall(b.id, { actorId: 'u1' }, CTX);
|
|
1247
|
-
expect(indexRows()).toHaveLength(0);
|
|
1248
|
-
});
|
|
1249
|
-
|
|
1250
|
-
it('unanimous partial approval shrinks the index to the still-pending set', async () => {
|
|
1251
|
-
const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX);
|
|
1252
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
1253
|
-
expect(indexRows()).toEqual([{ request_id: req.id, approver: 'u2' }]);
|
|
1254
|
-
});
|
|
1255
|
-
|
|
1256
|
-
it('reassign and SLA-reassign rewrite the index rows', async () => {
|
|
1257
|
-
const req = await svc.openNodeRequest(
|
|
1258
|
-
openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
|
|
1259
|
-
);
|
|
1260
|
-
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, SYS);
|
|
1261
|
-
expect(indexRows().map(r => r.approver).sort()).toEqual(['u2', 'u7']);
|
|
1262
|
-
|
|
1263
|
-
makeOverdue(req.id);
|
|
1264
|
-
await svc.runEscalations();
|
|
1265
|
-
expect(indexRows()).toEqual([{ request_id: req.id, approver: 'boss' }]);
|
|
1266
|
-
});
|
|
1267
|
-
|
|
1268
|
-
it('approver-filtered pages stay correct past the old 500-row scan window', async () => {
|
|
1269
|
-
// 30 u9 requests are the OLDEST rows, buried under 510 newer non-matching
|
|
1270
|
-
// ones — the pre-#1745 bounded scan (limit 500, newest-first) could never
|
|
1271
|
-
// reach them. Seeded directly: 540 openNodeRequest round-trips are noise.
|
|
1272
|
-
const reqs = (engine._tables['sys_approval_request'] ??= []);
|
|
1273
|
-
const idx = (engine._tables['sys_approval_approver'] ??= []);
|
|
1274
|
-
const ts = (i: number) => new Date(baseTime + i * 1000).toISOString();
|
|
1275
|
-
for (let i = 0; i < 30; i++) {
|
|
1276
|
-
reqs.push({
|
|
1277
|
-
id: `match_${i}`, process_name: 'flow:f', object_name: 'o', record_id: `m${i}`,
|
|
1278
|
-
status: 'pending', pending_approvers: 'u9', created_at: ts(i), updated_at: ts(i),
|
|
1279
|
-
});
|
|
1280
|
-
idx.push({ id: `aapr_m${i}`, request_id: `match_${i}`, approver: 'u9', organization_id: null, created_at: ts(i) });
|
|
1281
|
-
}
|
|
1282
|
-
for (let i = 0; i < 510; i++) {
|
|
1283
|
-
reqs.push({
|
|
1284
|
-
id: `noise_${i}`, process_name: 'flow:f', object_name: 'o', record_id: `n${i}`,
|
|
1285
|
-
status: 'pending', pending_approvers: 'someone-else', created_at: ts(100 + i), updated_at: ts(100 + i),
|
|
1286
|
-
});
|
|
1287
|
-
idx.push({ id: `aapr_n${i}`, request_id: `noise_${i}`, approver: 'someone-else', organization_id: null, created_at: ts(100 + i) });
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
expect(await svc.countRequests({ approverId: 'u9' }, SYS)).toBe(30);
|
|
1291
|
-
const page = await svc.listRequests({ approverId: 'u9', limit: 10, offset: 20 }, SYS);
|
|
1292
|
-
// Newest-first within the matches: offset 20 of 30 → match_9 … match_0.
|
|
1293
|
-
expect(page.map(r => r.id)).toEqual(Array.from({ length: 10 }, (_, k) => `match_${9 - k}`));
|
|
1294
|
-
expect(page.every(r => r.pending_approvers?.includes('u9'))).toBe(true);
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
it('rebuildApproverIndex backfills legacy rows, drops orphans + stale entries, and is idempotent', async () => {
|
|
1298
|
-
const reqs = (engine._tables['sys_approval_request'] ??= []);
|
|
1299
|
-
const idx = (engine._tables['sys_approval_approver'] ??= []);
|
|
1300
|
-
const ts = new Date(baseTime).toISOString();
|
|
1301
|
-
// Legacy pending row written before the index existed.
|
|
1302
|
-
reqs.push({
|
|
1303
|
-
id: 'legacy_1', process_name: 'flow:f', object_name: 'o', record_id: 'r1',
|
|
1304
|
-
status: 'pending', pending_approvers: 'u1,u2', created_at: ts, updated_at: ts,
|
|
1305
|
-
});
|
|
1306
|
-
// Completed row whose index rows were never cleaned (orphan).
|
|
1307
|
-
reqs.push({
|
|
1308
|
-
id: 'done_1', process_name: 'flow:f', object_name: 'o', record_id: 'r2',
|
|
1309
|
-
status: 'approved', pending_approvers: null, created_at: ts, updated_at: ts,
|
|
1310
|
-
});
|
|
1311
|
-
idx.push({ id: 'aapr_orphan', request_id: 'done_1', approver: 'u3', organization_id: null, created_at: ts });
|
|
1312
|
-
// Pending row whose index drifted (holds an approver no longer in the CSV).
|
|
1313
|
-
reqs.push({
|
|
1314
|
-
id: 'drift_1', process_name: 'flow:f', object_name: 'o', record_id: 'r3',
|
|
1315
|
-
status: 'pending', pending_approvers: 'u5', created_at: ts, updated_at: ts,
|
|
1316
|
-
});
|
|
1317
|
-
idx.push({ id: 'aapr_stale', request_id: 'drift_1', approver: 'u4', organization_id: null, created_at: ts });
|
|
1318
|
-
|
|
1319
|
-
const out = await svc.rebuildApproverIndex();
|
|
1320
|
-
expect(out).toEqual({ requests: 2, inserted: 3, deleted: 2 }); // +u1 +u2 +u5 / -orphan -stale
|
|
1321
|
-
expect(indexRows().sort((a, b) => a.approver.localeCompare(b.approver))).toEqual([
|
|
1322
|
-
{ request_id: 'legacy_1', approver: 'u1' },
|
|
1323
|
-
{ request_id: 'legacy_1', approver: 'u2' },
|
|
1324
|
-
{ request_id: 'drift_1', approver: 'u5' },
|
|
1325
|
-
]);
|
|
1326
|
-
|
|
1327
|
-
const again = await svc.rebuildApproverIndex();
|
|
1328
|
-
expect(again).toEqual({ requests: 2, inserted: 0, deleted: 0 });
|
|
1329
|
-
});
|
|
1330
|
-
|
|
1331
|
-
// ── SLA escalation (ADR-0042) ───────────────────────────────────
|
|
1332
|
-
|
|
1333
|
-
function makeOverdue(reqId: string) {
|
|
1334
|
-
// Push created_at into the past so a small timeoutHours is breached.
|
|
1335
|
-
const row = engine._tables['sys_approval_request'].find(r => r.id === reqId)!;
|
|
1336
|
-
row.created_at = new Date(baseTime - 10 * 3600_000).toISOString();
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
it('runEscalations: notify action messages approvers + escalateTo + submitter, once', async () => {
|
|
1340
|
-
const emitted: any[] = [];
|
|
1341
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
1342
|
-
const req = await svc.openNodeRequest(
|
|
1343
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 2, action: 'notify', escalateTo: 'boss', notifySubmitter: true } }), CTX,
|
|
1344
|
-
);
|
|
1345
|
-
makeOverdue(req.id);
|
|
1346
|
-
const first = await svc.runEscalations();
|
|
1347
|
-
expect(first.escalated).toBe(1);
|
|
1348
|
-
expect(emitted.map(e => e.topic)).toEqual(['approval.sla_breached', 'approval.sla_breached']);
|
|
1349
|
-
expect(emitted[0].audience).toEqual(['u9', 'boss']);
|
|
1350
|
-
expect(emitted[1].audience).toEqual(['u1']); // submitter
|
|
1351
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
1352
|
-
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla', comment: 'notify → boss' });
|
|
1353
|
-
// Single-shot: second sweep is a no-op.
|
|
1354
|
-
const second = await svc.runEscalations();
|
|
1355
|
-
expect(second.escalated).toBe(0);
|
|
1356
|
-
expect(emitted).toHaveLength(2);
|
|
1357
|
-
});
|
|
1358
|
-
|
|
1359
|
-
it('runEscalations: auto_approve decides as system:sla and resumes the flow', async () => {
|
|
1360
|
-
const resumed: any[] = [];
|
|
1361
|
-
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
1362
|
-
const req = await svc.openNodeRequest(
|
|
1363
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'auto_approve', notifySubmitter: false } }), CTX,
|
|
1364
|
-
);
|
|
1365
|
-
makeOverdue(req.id);
|
|
1366
|
-
const out = await svc.runEscalations();
|
|
1367
|
-
expect(out.escalated).toBe(1);
|
|
1368
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
1369
|
-
expect(fresh?.status).toBe('approved');
|
|
1370
|
-
expect(resumed[0]).toMatchObject({ runId: 'run_1', signal: { branchLabel: 'approve' } });
|
|
1371
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
1372
|
-
expect(actions.map(a => a.action)).toEqual(['submit', 'escalate', 'approve']);
|
|
1373
|
-
expect(actions.at(-1)?.actor_id).toBe('system:sla');
|
|
1374
|
-
});
|
|
1375
|
-
|
|
1376
|
-
it('runEscalations: auto_reject decides as system:sla', async () => {
|
|
1377
|
-
const req = await svc.openNodeRequest(
|
|
1378
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'auto_reject', notifySubmitter: false } }), CTX,
|
|
1379
|
-
);
|
|
1380
|
-
makeOverdue(req.id);
|
|
1381
|
-
await svc.runEscalations();
|
|
1382
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
1383
|
-
expect(fresh?.status).toBe('rejected');
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
it('runEscalations: reassign replaces the approver set with escalateTo', async () => {
|
|
1387
|
-
const req = await svc.openNodeRequest(
|
|
1388
|
-
openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
|
|
1389
|
-
);
|
|
1390
|
-
makeOverdue(req.id);
|
|
1391
|
-
await svc.runEscalations();
|
|
1392
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
1393
|
-
expect(fresh?.status).toBe('pending');
|
|
1394
|
-
expect(fresh?.pending_approvers).toEqual(['boss']);
|
|
1395
|
-
});
|
|
1396
|
-
|
|
1397
|
-
it('runEscalations: reassign expands a position escalateTo to its holders (ADR-0090 D3)', async () => {
|
|
1398
|
-
engine._tables['sys_user_position'] = [
|
|
1399
|
-
{ id: 'up1', user_id: 'u5', position: 'approvals_supervisor', organization_id: 't1' },
|
|
1400
|
-
{ id: 'up2', user_id: 'u6', position: 'approvals_supervisor', organization_id: 't1' },
|
|
1401
|
-
{ id: 'up3', user_id: 'u7', position: 'approvals_supervisor', organization_id: 't2' }, // other tenant
|
|
1402
|
-
];
|
|
1403
|
-
const req = await svc.openNodeRequest(
|
|
1404
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'approvals_supervisor', notifySubmitter: false } }), CTX,
|
|
1405
|
-
);
|
|
1406
|
-
makeOverdue(req.id);
|
|
1407
|
-
await svc.runEscalations();
|
|
1408
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
1409
|
-
expect(fresh?.status).toBe('pending');
|
|
1410
|
-
expect(fresh?.pending_approvers?.slice().sort()).toEqual(['u5', 'u6']);
|
|
1411
|
-
// The audit trail keeps the AUTHORED target, not the expansion.
|
|
1412
|
-
const actions = await svc.listActions(req.id, SYS);
|
|
1413
|
-
expect(actions.find(a => a.action === 'escalate')?.comment).toBe('reassign → approvals_supervisor');
|
|
1414
|
-
});
|
|
1415
|
-
|
|
1416
|
-
it('runEscalations: notify expands a position escalateTo into the audience', async () => {
|
|
1417
|
-
engine._tables['sys_user_position'] = [
|
|
1418
|
-
{ id: 'up1', user_id: 'u5', position: 'approvals_supervisor', organization_id: 't1' },
|
|
1419
|
-
];
|
|
1420
|
-
const emitted: any[] = [];
|
|
1421
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
1422
|
-
const req = await svc.openNodeRequest(
|
|
1423
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 2, action: 'notify', escalateTo: 'approvals_supervisor', notifySubmitter: false } }), CTX,
|
|
1424
|
-
);
|
|
1425
|
-
makeOverdue(req.id);
|
|
1426
|
-
await svc.runEscalations();
|
|
1427
|
-
expect(emitted).toHaveLength(1);
|
|
1428
|
-
expect(emitted[0].audience).toEqual(['u9', 'u5']);
|
|
1429
|
-
});
|
|
1430
|
-
|
|
1431
|
-
it('runEscalations: skips requests that are not yet due or have no SLA', async () => {
|
|
1432
|
-
await svc.openNodeRequest(
|
|
1433
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 1000, action: 'auto_approve' } }), CTX,
|
|
1434
|
-
);
|
|
1435
|
-
await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
|
|
1436
|
-
const out = await svc.runEscalations();
|
|
1437
|
-
expect(out.scanned).toBe(2);
|
|
1438
|
-
expect(out.escalated).toBe(0);
|
|
1439
|
-
});
|
|
1440
|
-
|
|
1441
|
-
// ── SLA + flow steps ────────────────────────────────────────────
|
|
1442
|
-
|
|
1443
|
-
it('rows expose sla_due_at when the node declares escalation.timeoutHours', async () => {
|
|
1444
|
-
const req = await svc.openNodeRequest(
|
|
1445
|
-
openInput(['u9'], {}, { escalation: { timeoutHours: 48, action: 'notify', notifySubmitter: true } }), CTX,
|
|
1446
|
-
);
|
|
1447
|
-
expect(req.sla_due_at).toBe(new Date(Date.parse(req.created_at!) + 48 * 3600_000).toISOString());
|
|
1448
|
-
const noSla = await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
|
|
1449
|
-
expect(noSla.sla_due_at).toBeUndefined();
|
|
1450
|
-
});
|
|
1451
|
-
|
|
1452
|
-
it('getRequest attaches flow_steps from the owning flow graph', async () => {
|
|
1453
|
-
svc.attachAutomation({
|
|
1454
|
-
async getFlow(name: string) {
|
|
1455
|
-
if (name !== 'deal_approval') return null;
|
|
1456
|
-
return {
|
|
1457
|
-
name: 'deal_approval',
|
|
1458
|
-
nodes: [
|
|
1459
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1460
|
-
{ id: 'approve_step', type: 'approval', label: 'Manager Approval' },
|
|
1461
|
-
{ id: 'gate', type: 'decision', label: 'Big?' },
|
|
1462
|
-
{ id: 'exec_step', type: 'approval', label: 'Executive Approval' },
|
|
1463
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1464
|
-
],
|
|
1465
|
-
edges: [
|
|
1466
|
-
{ id: 'e1', source: 'start', target: 'approve_step' },
|
|
1467
|
-
{ id: 'e2', source: 'approve_step', target: 'gate', label: 'approve' },
|
|
1468
|
-
{ id: 'e3', source: 'gate', target: 'exec_step', label: 'true' },
|
|
1469
|
-
{ id: 'e4', source: 'exec_step', target: 'end', label: 'approve' },
|
|
1470
|
-
],
|
|
1471
|
-
};
|
|
1472
|
-
},
|
|
1473
|
-
});
|
|
1474
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1475
|
-
const fresh = await svc.getRequest(req.id, SYS);
|
|
1476
|
-
expect(fresh?.flow_steps).toEqual([
|
|
1477
|
-
{ id: 'approve_step', label: 'Manager Approval', state: 'current' },
|
|
1478
|
-
{ id: 'exec_step', label: 'Executive Approval', state: 'upcoming' },
|
|
1479
|
-
]);
|
|
1480
|
-
});
|
|
1481
|
-
|
|
1482
|
-
it('enrichment resolves an email submitter via sys_user.email', async () => {
|
|
1483
|
-
engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }];
|
|
1484
|
-
await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX);
|
|
1485
|
-
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
1486
|
-
expect(rows[0].submitter_name).toBe('Grace Hopper');
|
|
1487
|
-
});
|
|
1488
|
-
});
|
|
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
|
-
|
|
1611
|
-
describe('record-lock hook (node era)', () => {
|
|
1612
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1613
|
-
let svc: ApprovalService;
|
|
1614
|
-
let n = 0;
|
|
1615
|
-
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
1616
|
-
|
|
1617
|
-
beforeEach(async () => {
|
|
1618
|
-
engine = makeFakeEngine();
|
|
1619
|
-
n = 0;
|
|
1620
|
-
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
|
|
1621
|
-
bindApprovalLockHook(engine as any);
|
|
1622
|
-
await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
1623
|
-
});
|
|
1624
|
-
|
|
1625
|
-
it('blocks a user edit to a record with a pending approval', async () => {
|
|
1626
|
-
await expect(
|
|
1627
|
-
engine.fire('beforeUpdate', {
|
|
1628
|
-
object: 'opportunity',
|
|
1629
|
-
input: { id: 'opp1', data: { amount: 200 } },
|
|
1630
|
-
session: { isSystem: false, positions: [], userId: 'u1' },
|
|
1631
|
-
}),
|
|
1632
|
-
).rejects.toThrow(/RECORD_LOCKED/);
|
|
1633
|
-
});
|
|
1634
|
-
|
|
1635
|
-
it('allows a status-mirror write (only the approvalStatusField changes)', async () => {
|
|
1636
|
-
await expect(
|
|
1637
|
-
engine.fire('beforeUpdate', {
|
|
1638
|
-
object: 'opportunity',
|
|
1639
|
-
input: { id: 'opp1', data: { approval_status: 'approved' } },
|
|
1640
|
-
session: { isSystem: false, positions: [] },
|
|
1641
|
-
}),
|
|
1642
|
-
).resolves.toBeUndefined();
|
|
1643
|
-
});
|
|
1644
|
-
|
|
1645
|
-
it('allows engine self-writes (system session)', async () => {
|
|
1646
|
-
await expect(
|
|
1647
|
-
engine.fire('beforeUpdate', {
|
|
1648
|
-
object: 'opportunity',
|
|
1649
|
-
input: { id: 'opp1', data: { amount: 200 } },
|
|
1650
|
-
session: { isSystem: true, positions: [] },
|
|
1651
|
-
}),
|
|
1652
|
-
).resolves.toBeUndefined();
|
|
1653
|
-
});
|
|
1654
|
-
|
|
1655
|
-
it('allows an admin override', async () => {
|
|
1656
|
-
await expect(
|
|
1657
|
-
engine.fire('beforeUpdate', {
|
|
1658
|
-
object: 'opportunity',
|
|
1659
|
-
input: { id: 'opp1', data: { amount: 200 } },
|
|
1660
|
-
session: { isSystem: false, roles: ['admin'] },
|
|
1661
|
-
}),
|
|
1662
|
-
).resolves.toBeUndefined();
|
|
1663
|
-
});
|
|
1664
|
-
|
|
1665
|
-
it('does not lock records without a pending request', async () => {
|
|
1666
|
-
await expect(
|
|
1667
|
-
engine.fire('beforeUpdate', {
|
|
1668
|
-
object: 'opportunity',
|
|
1669
|
-
input: { id: 'other_record', data: { amount: 200 } },
|
|
1670
|
-
session: { isSystem: false, positions: [] },
|
|
1671
|
-
}),
|
|
1672
|
-
).resolves.toBeUndefined();
|
|
1673
|
-
});
|
|
1674
|
-
|
|
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();
|
|
1688
|
-
});
|
|
1689
|
-
|
|
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
|
-
});
|
|
1703
|
-
|
|
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
|
-
}));
|
|
1896
|
-
}
|
|
1897
|
-
|
|
1898
|
-
beforeEach(() => {
|
|
1899
|
-
engine = makeFakeEngine();
|
|
1900
|
-
emitted = [];
|
|
1901
|
-
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(OOO_NOW) } });
|
|
1902
|
-
svc.attachMessaging({ emit: async (m: any) => { emitted.push(m); } });
|
|
1903
|
-
});
|
|
1904
|
-
|
|
1905
|
-
it('type:user — reroutes an out-of-office approver to the delegate', async () => {
|
|
1906
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1907
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1908
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1909
|
-
});
|
|
1910
|
-
|
|
1911
|
-
it('records an ooo_substitute audit action with "A → B — reason"', async () => {
|
|
1912
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1913
|
-
await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1914
|
-
const sub = engine._tables['sys_approval_action'].find((a: any) => a.action === 'ooo_substitute');
|
|
1915
|
-
expect(sub).toBeTruthy();
|
|
1916
|
-
expect(sub.comment).toBe('alice → bob — Annual leave');
|
|
1917
|
-
expect(sub.actor_id).toBeNull(); // system-recorded reroute, no human actor
|
|
1918
|
-
});
|
|
1919
|
-
|
|
1920
|
-
it('notifies both the delegate and the skipped approver', async () => {
|
|
1921
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1922
|
-
await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1923
|
-
const to = emitted.find(e => e.topic === 'approval.ooo_substituted');
|
|
1924
|
-
const from = emitted.find(e => e.topic === 'approval.ooo_skipped');
|
|
1925
|
-
expect(to?.audience).toEqual(['bob']);
|
|
1926
|
-
expect(from?.audience).toEqual(['alice']);
|
|
1927
|
-
});
|
|
1928
|
-
|
|
1929
|
-
it('does not reroute before valid_from (window not yet open)', async () => {
|
|
1930
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_from: '2026-05-28T00:00:00Z' }]);
|
|
1931
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1932
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
1933
|
-
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
|
|
1934
|
-
});
|
|
1935
|
-
|
|
1936
|
-
it('does not reroute at/after valid_until (half-open window)', async () => {
|
|
1937
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_until: '2026-05-27T10:00:00Z' }]);
|
|
1938
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1939
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
1940
|
-
});
|
|
1941
|
-
|
|
1942
|
-
it('type:field — reroutes the user stored in the record field', async () => {
|
|
1943
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1944
|
-
const input = {
|
|
1945
|
-
...openInput([]),
|
|
1946
|
-
record: { id: 'opp1', reviewer: 'alice' },
|
|
1947
|
-
config: { approvers: [{ type: 'field', value: 'reviewer' }], behavior: 'first_response', lockRecord: true },
|
|
1948
|
-
};
|
|
1949
|
-
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1950
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1951
|
-
});
|
|
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
|
-
|
|
1967
|
-
it('type:manager — reroutes when the resolved manager is out of office', async () => {
|
|
1968
|
-
engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
|
|
1969
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1970
|
-
const input = {
|
|
1971
|
-
...openInput([]),
|
|
1972
|
-
record: { id: 'opp1', owner_id: 'carol' },
|
|
1973
|
-
config: { approvers: [{ type: 'manager', value: 'owner_id' }], behavior: 'first_response', lockRecord: true },
|
|
1974
|
-
};
|
|
1975
|
-
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1976
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1977
|
-
});
|
|
1978
|
-
|
|
1979
|
-
it('follows a delegation chain A → B → C', async () => {
|
|
1980
|
-
seedDelegation([
|
|
1981
|
-
{ delegator_id: 'alice', delegate_id: 'bob' },
|
|
1982
|
-
{ delegator_id: 'bob', delegate_id: 'carol' },
|
|
1983
|
-
]);
|
|
1984
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1985
|
-
expect(req.pending_approvers).toEqual(['carol']);
|
|
1986
|
-
expect(engine._tables['sys_approval_action'].filter((a: any) => a.action === 'ooo_substitute')).toHaveLength(2);
|
|
1987
|
-
});
|
|
1988
|
-
|
|
1989
|
-
it('stops on a cycle A → B → A without looping', async () => {
|
|
1990
|
-
seedDelegation([
|
|
1991
|
-
{ delegator_id: 'alice', delegate_id: 'bob' },
|
|
1992
|
-
{ delegator_id: 'bob', delegate_id: 'alice' },
|
|
1993
|
-
]);
|
|
1994
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1995
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
1996
|
-
});
|
|
1997
|
-
|
|
1998
|
-
it('ignores a self-delegation (A → A)', async () => {
|
|
1999
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'alice' }]);
|
|
2000
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
2001
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
2002
|
-
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
|
|
2003
|
-
});
|
|
2004
|
-
|
|
2005
|
-
it('leaves approvers unchanged when there is no active delegation', async () => {
|
|
2006
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
2007
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
2008
|
-
});
|
|
2009
|
-
|
|
2010
|
-
it('does not OOO-substitute group-routed (position) approvers', async () => {
|
|
2011
|
-
engine._tables['sys_user_position'] = [{ id: 'up1', user_id: 'alice', position: 'sales_manager', organization_id: 't1' }];
|
|
2012
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
2013
|
-
const input = {
|
|
2014
|
-
...openInput([]),
|
|
2015
|
-
config: { approvers: [{ type: 'position', value: 'sales_manager' }], behavior: 'first_response', lockRecord: true },
|
|
2016
|
-
};
|
|
2017
|
-
const req = await svc.openNodeRequest(input as any, CTX);
|
|
2018
|
-
// Position-routed leave is ADR-0091's job, not this path: the holder stays.
|
|
2019
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
2020
|
-
});
|
|
2021
|
-
|
|
2022
|
-
it('respects tenant scope: a rule scoped to another org does not apply', async () => {
|
|
2023
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: 't2' }]);
|
|
2024
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
2025
|
-
expect(req.pending_approvers).toEqual(['alice']);
|
|
2026
|
-
});
|
|
2027
|
-
|
|
2028
|
-
it('applies a cross-tenant (null org) rule regardless of request tenant', async () => {
|
|
2029
|
-
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: null }]);
|
|
2030
|
-
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
2031
|
-
expect(req.pending_approvers).toEqual(['bob']);
|
|
2032
|
-
});
|
|
2033
|
-
});
|
|
2034
|
-
|
|
2035
|
-
// ── Delegation self-service write guard (#1322 follow-up) ─────────────
|
|
2036
|
-
//
|
|
2037
|
-
// sys_approval_delegation is apiEnabled CRUD; a member must not be able to
|
|
2038
|
-
// forge a delegation for someone else (delegator_id = victim) and reroute the
|
|
2039
|
-
// victim's approvals. The guard forces delegator_id == acting user for normal
|
|
2040
|
-
// writes; system/admin contexts bypass. Row-ownership on update/delete is the
|
|
2041
|
-
// platform's created_by RLS (not exercised here).
|
|
2042
|
-
describe('sys_approval_delegation write guard (#1322)', () => {
|
|
2043
|
-
const DEL = 'sys_approval_delegation';
|
|
2044
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
2045
|
-
|
|
2046
|
-
beforeEach(() => {
|
|
2047
|
-
engine = makeFakeEngine();
|
|
2048
|
-
bindDelegationWriteGuard(engine as any);
|
|
2049
|
-
});
|
|
2050
|
-
|
|
2051
|
-
const fireInsert = (data: any, session: any) =>
|
|
2052
|
-
(engine as any).fire('beforeInsert', { object: DEL, input: { data }, session });
|
|
2053
|
-
const fireUpdate = (data: any, session: any) =>
|
|
2054
|
-
(engine as any).fire('beforeUpdate', { object: DEL, input: { id: data?.id ?? 'd1', data }, session });
|
|
2055
|
-
const member = (userId?: string) => ({ isSystem: false, roles: [], ...(userId ? { userId } : {}) });
|
|
2056
|
-
|
|
2057
|
-
it('allows a member to create their own delegation', async () => {
|
|
2058
|
-
await expect(fireInsert({ delegator_id: 'u1', delegate_id: 'u2' }, member('u1'))).resolves.toBeUndefined();
|
|
2059
|
-
});
|
|
2060
|
-
|
|
2061
|
-
it('rejects a member forging a delegation for someone else', async () => {
|
|
2062
|
-
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
2063
|
-
});
|
|
2064
|
-
|
|
2065
|
-
it('stamps the caller as delegator when omitted on insert', async () => {
|
|
2066
|
-
const data: any = { delegate_id: 'u2' };
|
|
2067
|
-
await fireInsert(data, member('u1'));
|
|
2068
|
-
expect(data.delegator_id).toBe('u1');
|
|
2069
|
-
});
|
|
2070
|
-
|
|
2071
|
-
it('rejects an unauthenticated non-system insert', async () => {
|
|
2072
|
-
await expect(fireInsert({ delegate_id: 'u2' }, member())).rejects.toThrow(/FORBIDDEN/);
|
|
2073
|
-
});
|
|
2074
|
-
|
|
2075
|
-
it('bypasses the guard for system context', async () => {
|
|
2076
|
-
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, { isSystem: true })).resolves.toBeUndefined();
|
|
2077
|
-
});
|
|
2078
|
-
|
|
2079
|
-
it('lets an admin set the delegator to anyone', async () => {
|
|
2080
|
-
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u2' }, { isSystem: false, roles: ['admin'], userId: 'admin1' })).resolves.toBeUndefined();
|
|
2081
|
-
});
|
|
2082
|
-
|
|
2083
|
-
it('rejects a member relabelling delegator on update', async () => {
|
|
2084
|
-
await expect(fireUpdate({ id: 'd1', delegator_id: 'victim' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
2085
|
-
});
|
|
2086
|
-
|
|
2087
|
-
it('allows a member update that does not touch delegator_id', async () => {
|
|
2088
|
-
await expect(fireUpdate({ id: 'd1', valid_until: '2026-06-01T00:00:00Z' }, member('u1'))).resolves.toBeUndefined();
|
|
2089
|
-
});
|
|
2090
|
-
|
|
2091
|
-
it('rejects a batch insert if any row names a foreign delegator', async () => {
|
|
2092
|
-
await expect(fireInsert(
|
|
2093
|
-
[{ delegator_id: 'u1', delegate_id: 'u2' }, { delegator_id: 'victim', delegate_id: 'u3' }],
|
|
2094
|
-
member('u1'),
|
|
2095
|
-
)).rejects.toThrow(/FORBIDDEN/);
|
|
2096
|
-
});
|
|
2097
|
-
});
|
|
2098
|
-
|
|
2099
|
-
// ── Quorum & per-group sign-off (#3266) ───────────────────────────────
|
|
2100
|
-
//
|
|
2101
|
-
// quorum = M-of-N collective sign-off; per_group = one (or minApprovals) from
|
|
2102
|
-
// EACH group (会签). A single rejection is always a veto. Group membership is
|
|
2103
|
-
// snapshotted at open, so OOO-substituted approvers count for their group.
|
|
2104
|
-
describe('ApprovalService — quorum & per_group (#3266)', () => {
|
|
2105
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
2106
|
-
let svc: ApprovalService;
|
|
2107
|
-
const base = new Date('2026-08-01T10:00:00Z').getTime();
|
|
2108
|
-
|
|
2109
|
-
beforeEach(() => {
|
|
2110
|
-
engine = makeFakeEngine();
|
|
2111
|
-
let n = 0;
|
|
2112
|
-
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
|
|
2113
|
-
});
|
|
2114
|
-
|
|
2115
|
-
// Build an openNodeRequest input with explicit approver specs + behavior.
|
|
2116
|
-
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
|
|
2117
|
-
...openInput([]),
|
|
2118
|
-
config: { approvers, behavior, lockRecord: true, ...extra },
|
|
2119
|
-
});
|
|
2120
|
-
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
|
|
2121
|
-
|
|
2122
|
-
it('quorum: holds until minApprovals reached, then finalizes', async () => {
|
|
2123
|
-
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
2124
|
-
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
2125
|
-
expect(a.finalized).toBe(false);
|
|
2126
|
-
expect(a.request.status).toBe('pending');
|
|
2127
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
|
|
2128
|
-
expect(b.finalized).toBe(true);
|
|
2129
|
-
expect(b.request.status).toBe('approved');
|
|
2130
|
-
});
|
|
2131
|
-
|
|
2132
|
-
it('quorum: minApprovals clamps to the approver count (no deadlock)', async () => {
|
|
2133
|
-
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2')], 'quorum', { minApprovals: 5 }), CTX);
|
|
2134
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
2135
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
|
|
2136
|
-
expect(b.finalized).toBe(true);
|
|
2137
|
-
});
|
|
2138
|
-
|
|
2139
|
-
it('quorum: any reject is a veto', async () => {
|
|
2140
|
-
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
2141
|
-
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u1' }, SYS);
|
|
2142
|
-
expect(r.finalized).toBe(true);
|
|
2143
|
-
expect(r.request.status).toBe('rejected');
|
|
2144
|
-
});
|
|
2145
|
-
|
|
2146
|
-
it('per_group: advances only when EACH group approves', async () => {
|
|
2147
|
-
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
2148
|
-
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
2149
|
-
expect(a.finalized).toBe(false); // finance still pending
|
|
2150
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
|
|
2151
|
-
expect(b.finalized).toBe(true);
|
|
2152
|
-
expect(b.request.status).toBe('approved');
|
|
2153
|
-
});
|
|
2154
|
-
|
|
2155
|
-
it('per_group: two approvals in ONE group do not satisfy another group', async () => {
|
|
2156
|
-
const req = await svc.openNodeRequest(
|
|
2157
|
-
cfg([U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
2158
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
2159
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l2' }, SYS);
|
|
2160
|
-
expect(b.finalized).toBe(false); // finance still missing
|
|
2161
|
-
});
|
|
2162
|
-
|
|
2163
|
-
it('per_group: minApprovals=2 needs two from each group', async () => {
|
|
2164
|
-
const req = await svc.openNodeRequest(cfg(
|
|
2165
|
-
[U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance'), U('f2', 'finance')],
|
|
2166
|
-
'per_group', { minApprovals: 2 }), CTX);
|
|
2167
|
-
for (const u of ['l1', 'f1', 'l2']) {
|
|
2168
|
-
const r = await svc.decideNode(req.id, { decision: 'approve', actorId: u }, SYS);
|
|
2169
|
-
expect(r.finalized).toBe(false);
|
|
2170
|
-
}
|
|
2171
|
-
const done = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f2' }, SYS);
|
|
2172
|
-
expect(done.finalized).toBe(true);
|
|
2173
|
-
});
|
|
2174
|
-
|
|
2175
|
-
it('per_group: reject is a veto', async () => {
|
|
2176
|
-
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
2177
|
-
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'l1' }, SYS);
|
|
2178
|
-
expect(r.request.status).toBe('rejected');
|
|
2179
|
-
});
|
|
2180
|
-
|
|
2181
|
-
it('per_group: an OOO-substituted member still counts for their group', async () => {
|
|
2182
|
-
engine._tables['sys_approval_delegation'] = [
|
|
2183
|
-
{ id: 'd', delegator_id: 'l1', delegate_id: 'lb', organization_id: 't1', valid_from: null, valid_until: null, reason: 'leave' },
|
|
2184
|
-
];
|
|
2185
|
-
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
2186
|
-
expect(req.pending_approvers).toContain('lb');
|
|
2187
|
-
expect(req.pending_approvers).not.toContain('l1');
|
|
2188
|
-
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'lb' }, SYS); // delegate covers legal
|
|
2189
|
-
expect(a.finalized).toBe(false);
|
|
2190
|
-
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
|
|
2191
|
-
expect(b.finalized).toBe(true);
|
|
2192
|
-
});
|
|
2193
|
-
|
|
2194
|
-
it('records decision attachments on the audit row', async () => {
|
|
2195
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2196
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_1', 'file_2'] }, SYS);
|
|
2197
|
-
const act = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
|
|
2198
|
-
expect(act.attachments).toEqual(['file_1', 'file_2']);
|
|
2199
|
-
});
|
|
2200
|
-
});
|
|
2201
|
-
|
|
2202
|
-
// ── Decision progress + notification deep links (#2678 P1.5) ──────────
|
|
2203
|
-
describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () => {
|
|
2204
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
2205
|
-
let svc: ApprovalService;
|
|
2206
|
-
|
|
2207
|
-
beforeEach(() => {
|
|
2208
|
-
engine = makeFakeEngine();
|
|
2209
|
-
let n = 0;
|
|
2210
|
-
const base = new Date('2026-09-01T09:00:00Z').getTime();
|
|
2211
|
-
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
|
|
2212
|
-
});
|
|
2213
|
-
|
|
2214
|
-
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
|
|
2215
|
-
...openInput([]),
|
|
2216
|
-
config: { approvers, behavior, lockRecord: true, ...extra },
|
|
2217
|
-
});
|
|
2218
|
-
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
|
|
2219
|
-
|
|
2220
|
-
it('per_group: getRequest exposes per-group progress that updates per approval', async () => {
|
|
2221
|
-
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
2222
|
-
let row: any = await svc.getRequest(req.id, SYS);
|
|
2223
|
-
expect(row.decision_progress).toMatchObject({ behavior: 'per_group', got: 0, need: 2 });
|
|
2224
|
-
expect(row.decision_progress.groups).toEqual([
|
|
2225
|
-
{ group: 'finance', got: 0, need: 1, satisfied: false },
|
|
2226
|
-
{ group: 'legal', got: 0, need: 1, satisfied: false },
|
|
2227
|
-
]);
|
|
2228
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
2229
|
-
row = await svc.getRequest(req.id, SYS);
|
|
2230
|
-
expect(row.decision_progress.got).toBe(1);
|
|
2231
|
-
expect(row.decision_progress.groups.find((g: any) => g.group === 'legal')).toMatchObject({ got: 1, satisfied: true });
|
|
2232
|
-
expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
|
|
2233
|
-
});
|
|
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
|
-
|
|
2262
|
-
it('quorum: progress reports approvals against the clamped threshold', async () => {
|
|
2263
|
-
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
2264
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
2265
|
-
const row: any = await svc.getRequest(req.id, SYS);
|
|
2266
|
-
expect(row.decision_progress).toMatchObject({ behavior: 'quorum', got: 1, need: 2 });
|
|
2267
|
-
});
|
|
2268
|
-
|
|
2269
|
-
it('first_response: no decision_progress', async () => {
|
|
2270
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2271
|
-
const row: any = await svc.getRequest(req.id, SYS);
|
|
2272
|
-
expect(row.decision_progress).toBeUndefined();
|
|
2273
|
-
});
|
|
2274
|
-
|
|
2275
|
-
it('notify: inbox actionUrl is rewritten to a request deep link', async () => {
|
|
2276
|
-
const emitted: any[] = [];
|
|
2277
|
-
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
2278
|
-
const req = await svc.openNodeRequest(openInput(['u1', 'u2']), CTX);
|
|
2279
|
-
await svc.reassign(req.id, { actorId: 'u1', to: 'u7' }, SYS);
|
|
2280
|
-
const note = emitted.find(e => e.topic === 'approval.reassigned');
|
|
2281
|
-
expect(note.payload.actionUrl).toBe(`/system/approvals?request=${encodeURIComponent(req.id)}`);
|
|
2282
|
-
});
|
|
2283
|
-
});
|
|
2284
|
-
|
|
2285
|
-
// listActions must surface decision attachments through the contract mapping
|
|
2286
|
-
// (#3266 — the column existed but rowFromAction dropped it; caught in browser).
|
|
2287
|
-
describe('ApprovalService — listActions attachments mapping (#3266)', () => {
|
|
2288
|
-
it('normalizes a bare fileId string into an attachment descriptor', async () => {
|
|
2289
|
-
const engine = makeFakeEngine();
|
|
2290
|
-
let n = 0;
|
|
2291
|
-
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
|
|
2292
|
-
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
2293
|
-
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
|
|
2294
|
-
const acts = await svc.listActions(req.id, SYS);
|
|
2295
|
-
const approve = acts.find(a => a.action === 'approve');
|
|
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');
|
|
2857
|
-
});
|
|
2858
|
-
});
|