@objectstack/plugin-approvals 15.1.1 → 16.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +168 -0
- package/dist/index.d.mts +3024 -2413
- package/dist/index.d.ts +3024 -2413
- package/dist/index.js +882 -97
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +883 -98
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/scripts/i18n-extract.config.ts +2 -1
- package/src/approval-service.test.ts +464 -1
- package/src/approval-service.ts +425 -58
- package/src/approvals-plugin.ts +9 -3
- package/src/index.ts +1 -0
- package/src/lifecycle-hooks.ts +64 -0
- package/src/nav-contribution.test.ts +2 -0
- package/src/sys-approval-action.object.ts +17 -2
- package/src/sys-approval-approver.object.ts +7 -1
- package/src/sys-approval-delegation.object.ts +142 -0
- package/src/sys-approval-request.object.test.ts +103 -0
- package/src/sys-approval-request.object.ts +170 -1
- package/src/sys-approval-token.object.ts +7 -1
- package/src/translations/en.objects.generated.ts +49 -0
- package/src/translations/es-ES.objects.generated.ts +49 -0
- package/src/translations/ja-JP.objects.generated.ts +49 -0
- package/src/translations/zh-CN.objects.generated.ts +49 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "16.0.0-rc.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "
|
|
17
|
-
"@objectstack/formula": "
|
|
18
|
-
"@objectstack/metadata-core": "
|
|
19
|
-
"@objectstack/
|
|
20
|
-
"@objectstack/
|
|
16
|
+
"@objectstack/core": "16.0.0-rc.1",
|
|
17
|
+
"@objectstack/formula": "16.0.0-rc.1",
|
|
18
|
+
"@objectstack/metadata-core": "16.0.0-rc.1",
|
|
19
|
+
"@objectstack/platform-objects": "16.0.0-rc.1",
|
|
20
|
+
"@objectstack/spec": "16.0.0-rc.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^26.1.1",
|
|
24
24
|
"typescript": "^6.0.3",
|
|
25
25
|
"vitest": "^4.1.10",
|
|
26
|
-
"@objectstack/service-automation": "
|
|
26
|
+
"@objectstack/service-automation": "16.0.0-rc.1"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"objectstack",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { defineStack } from '@objectstack/spec';
|
|
16
16
|
import { SysApprovalRequest } from '../src/sys-approval-request.object.js';
|
|
17
17
|
import { SysApprovalAction } from '../src/sys-approval-action.object.js';
|
|
18
|
+
import { SysApprovalDelegation } from '../src/sys-approval-delegation.object.js';
|
|
18
19
|
import { enObjects } from '../src/translations/en.objects.generated.js';
|
|
19
20
|
import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
|
|
20
21
|
import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js';
|
|
@@ -22,7 +23,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js';
|
|
|
22
23
|
|
|
23
24
|
export default defineStack({
|
|
24
25
|
name: 'plugin-approvals-i18n-extract',
|
|
25
|
-
objects: [SysApprovalRequest, SysApprovalAction] as any,
|
|
26
|
+
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalDelegation] as any,
|
|
26
27
|
translations: [
|
|
27
28
|
{ en: { objects: enObjects } },
|
|
28
29
|
{ 'zh-CN': { objects: zhCNObjects } },
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
13
13
|
import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
|
|
14
|
-
import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
|
|
14
|
+
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
|
|
15
15
|
|
|
16
16
|
interface FakeRow { [k: string]: any }
|
|
17
17
|
|
|
@@ -227,6 +227,57 @@ describe('ApprovalService (node era)', () => {
|
|
|
227
227
|
expect(req.pending_approvers).toEqual(['position:sales_manager']);
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
// ── approver expansion: org_membership_level + its deprecated `role` alias
|
|
231
|
+
// (ADR-0090 D3) ────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
// `recordId` is parameterised: the service rejects a second pending request
|
|
234
|
+
// on the same record, and the alias test deliberately opens two.
|
|
235
|
+
const tierInput = (type: 'org_membership_level' | 'role', recordId = 'opp1') => ({
|
|
236
|
+
...openInput([]),
|
|
237
|
+
recordId,
|
|
238
|
+
record: { id: recordId, amount: 100 },
|
|
239
|
+
config: {
|
|
240
|
+
approvers: [{ type: type as any, value: 'admin' }],
|
|
241
|
+
behavior: 'first_response' as const,
|
|
242
|
+
lockRecord: true,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('org_membership_level approver: expands the better-auth tier, org-scoped', async () => {
|
|
247
|
+
engine._tables['sys_member'] = [
|
|
248
|
+
{ id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
|
|
249
|
+
{ id: 'm2', user_id: 'u2', role: 'admin', organization_id: 't1' },
|
|
250
|
+
{ id: 'm3', user_id: 'u3', role: 'admin', organization_id: 't2' }, // other tenant
|
|
251
|
+
{ id: 'm4', user_id: 'u4', role: 'member', organization_id: 't1' }, // other tier
|
|
252
|
+
];
|
|
253
|
+
const req = await svc.openNodeRequest(tierInput('org_membership_level'), CTX);
|
|
254
|
+
expect(req.pending_approvers.sort()).toEqual(['u1', 'u2']);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('deprecated `role` alias resolves IDENTICALLY to org_membership_level', async () => {
|
|
258
|
+
engine._tables['sys_member'] = [
|
|
259
|
+
{ id: 'm1', user_id: 'u1', role: 'admin', organization_id: 't1' },
|
|
260
|
+
{ id: 'm2', user_id: 'u4', role: 'member', organization_id: 't1' },
|
|
261
|
+
];
|
|
262
|
+
const canonical = await svc.openNodeRequest(tierInput('org_membership_level', 'opp_canon'), CTX);
|
|
263
|
+
const deprecated = await svc.openNodeRequest(tierInput('role', 'opp_depr'), CTX);
|
|
264
|
+
expect(deprecated.pending_approvers).toEqual(canonical.pending_approvers);
|
|
265
|
+
expect(deprecated.pending_approvers).toEqual(['u1']);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// The fallback literal keeps the AUTHORED spelling: `sys_approval_approver`
|
|
269
|
+
// rows and `pending_approvers` slots written by 15.x carry `role:<v>`, and
|
|
270
|
+
// canonicalising the literal here would orphan every one of them.
|
|
271
|
+
it('deprecated `role` alias keeps its legacy literal on fallback (no orphaned slots)', async () => {
|
|
272
|
+
const req = await svc.openNodeRequest(tierInput('role'), CTX);
|
|
273
|
+
expect(req.pending_approvers).toEqual(['role:admin']);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('org_membership_level falls back to its own canonical literal', async () => {
|
|
277
|
+
const req = await svc.openNodeRequest(tierInput('org_membership_level'), CTX);
|
|
278
|
+
expect(req.pending_approvers).toEqual(['org_membership_level:admin']);
|
|
279
|
+
});
|
|
280
|
+
|
|
230
281
|
it("department approver: honors the spec enum value 'department' (not just the business_unit dialect)", async () => {
|
|
231
282
|
engine._tables['sys_business_unit'] = [
|
|
232
283
|
{ id: 'bu1', organization_id: 't1', active: true },
|
|
@@ -363,6 +414,33 @@ describe('ApprovalService (node era)', () => {
|
|
|
363
414
|
expect(await svc.getRequest('nope', SYS)).toBeNull();
|
|
364
415
|
});
|
|
365
416
|
|
|
417
|
+
// ── viewer capability (#3310) ───────────────────────────────────
|
|
418
|
+
it('getRequest: viewer.can_act is true for a pending approver, false for the submitter', async () => {
|
|
419
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
|
|
420
|
+
const asApprover = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
421
|
+
expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false });
|
|
422
|
+
const asSubmitter = await svc.getRequest(req.id, { userId: 'u1', tenantId: 't1' } as any);
|
|
423
|
+
expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true });
|
|
424
|
+
const asOther = await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any);
|
|
425
|
+
expect(asOther!.viewer).toEqual({ can_act: false, is_submitter: false });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('getRequest: viewer.can_act drops to false once the request is finalized', async () => {
|
|
429
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
430
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); // → approved
|
|
431
|
+
const after = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
432
|
+
expect(after!.status).toBe('approved');
|
|
433
|
+
expect(after!.viewer!.can_act).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('listRequests: attaches viewer to every row from the caller context', async () => {
|
|
437
|
+
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
438
|
+
const rows = await svc.listRequests({ status: 'pending' }, { userId: 'u9', tenantId: 't1' } as any);
|
|
439
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
440
|
+
expect(rows.every(r => r.viewer != null)).toBe(true);
|
|
441
|
+
expect(rows[0].viewer!.can_act).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
366
444
|
// ── recall ──────────────────────────────────────────────────────
|
|
367
445
|
|
|
368
446
|
it('recall: submitter withdraws a pending request', async () => {
|
|
@@ -1025,3 +1103,388 @@ describe('record-lock hook (node era)', () => {
|
|
|
1025
1103
|
expect(engine._hooks['beforeUpdate']).toHaveLength(0);
|
|
1026
1104
|
});
|
|
1027
1105
|
});
|
|
1106
|
+
|
|
1107
|
+
// ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
|
|
1108
|
+
//
|
|
1109
|
+
// When a resolved individual approver has declared an active OOO delegation,
|
|
1110
|
+
// the slot is rerouted to the delegate at resolution time (never a background
|
|
1111
|
+
// job), audited as `ooo_substitute`, and both parties are notified. Group /
|
|
1112
|
+
// graph approvers (position/team/department/tier) are left untouched.
|
|
1113
|
+
describe('ApprovalService — out-of-office delegation (#1322)', () => {
|
|
1114
|
+
// Mid-window instant for the issue's own example (leave 5/26–5/30).
|
|
1115
|
+
const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
|
|
1116
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1117
|
+
let svc: ApprovalService;
|
|
1118
|
+
let emitted: any[];
|
|
1119
|
+
|
|
1120
|
+
function seedDelegation(rows: Array<Record<string, any>>) {
|
|
1121
|
+
engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
|
|
1122
|
+
id: `del${i}`,
|
|
1123
|
+
organization_id: 't1',
|
|
1124
|
+
valid_from: '2026-05-26T00:00:00Z',
|
|
1125
|
+
valid_until: '2026-05-30T00:00:00Z',
|
|
1126
|
+
reason: 'Annual leave',
|
|
1127
|
+
...r,
|
|
1128
|
+
}));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
beforeEach(() => {
|
|
1132
|
+
engine = makeFakeEngine();
|
|
1133
|
+
emitted = [];
|
|
1134
|
+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(OOO_NOW) } });
|
|
1135
|
+
svc.attachMessaging({ emit: async (m: any) => { emitted.push(m); } });
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('type:user — reroutes an out-of-office approver to the delegate', async () => {
|
|
1139
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1140
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1141
|
+
expect(req.pending_approvers).toEqual(['bob']);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it('records an ooo_substitute audit action with "A → B — reason"', async () => {
|
|
1145
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1146
|
+
await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1147
|
+
const sub = engine._tables['sys_approval_action'].find((a: any) => a.action === 'ooo_substitute');
|
|
1148
|
+
expect(sub).toBeTruthy();
|
|
1149
|
+
expect(sub.comment).toBe('alice → bob — Annual leave');
|
|
1150
|
+
expect(sub.actor_id).toBeNull(); // system-recorded reroute, no human actor
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
it('notifies both the delegate and the skipped approver', async () => {
|
|
1154
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1155
|
+
await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1156
|
+
const to = emitted.find(e => e.topic === 'approval.ooo_substituted');
|
|
1157
|
+
const from = emitted.find(e => e.topic === 'approval.ooo_skipped');
|
|
1158
|
+
expect(to?.audience).toEqual(['bob']);
|
|
1159
|
+
expect(from?.audience).toEqual(['alice']);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it('does not reroute before valid_from (window not yet open)', async () => {
|
|
1163
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_from: '2026-05-28T00:00:00Z' }]);
|
|
1164
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1165
|
+
expect(req.pending_approvers).toEqual(['alice']);
|
|
1166
|
+
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it('does not reroute at/after valid_until (half-open window)', async () => {
|
|
1170
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_until: '2026-05-27T10:00:00Z' }]);
|
|
1171
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1172
|
+
expect(req.pending_approvers).toEqual(['alice']);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it('type:field — reroutes the user stored in the record field', async () => {
|
|
1176
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1177
|
+
const input = {
|
|
1178
|
+
...openInput([]),
|
|
1179
|
+
record: { id: 'opp1', reviewer: 'alice' },
|
|
1180
|
+
config: { approvers: [{ type: 'field', value: 'reviewer' }], behavior: 'first_response', lockRecord: true },
|
|
1181
|
+
};
|
|
1182
|
+
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1183
|
+
expect(req.pending_approvers).toEqual(['bob']);
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
it('type:manager — reroutes when the resolved manager is out of office', async () => {
|
|
1187
|
+
engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
|
|
1188
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1189
|
+
const input = {
|
|
1190
|
+
...openInput([]),
|
|
1191
|
+
record: { id: 'opp1', owner_id: 'carol' },
|
|
1192
|
+
config: { approvers: [{ type: 'manager', value: 'owner_id' }], behavior: 'first_response', lockRecord: true },
|
|
1193
|
+
};
|
|
1194
|
+
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1195
|
+
expect(req.pending_approvers).toEqual(['bob']);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
it('follows a delegation chain A → B → C', async () => {
|
|
1199
|
+
seedDelegation([
|
|
1200
|
+
{ delegator_id: 'alice', delegate_id: 'bob' },
|
|
1201
|
+
{ delegator_id: 'bob', delegate_id: 'carol' },
|
|
1202
|
+
]);
|
|
1203
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1204
|
+
expect(req.pending_approvers).toEqual(['carol']);
|
|
1205
|
+
expect(engine._tables['sys_approval_action'].filter((a: any) => a.action === 'ooo_substitute')).toHaveLength(2);
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('stops on a cycle A → B → A without looping', async () => {
|
|
1209
|
+
seedDelegation([
|
|
1210
|
+
{ delegator_id: 'alice', delegate_id: 'bob' },
|
|
1211
|
+
{ delegator_id: 'bob', delegate_id: 'alice' },
|
|
1212
|
+
]);
|
|
1213
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1214
|
+
expect(req.pending_approvers).toEqual(['bob']);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
it('ignores a self-delegation (A → A)', async () => {
|
|
1218
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'alice' }]);
|
|
1219
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1220
|
+
expect(req.pending_approvers).toEqual(['alice']);
|
|
1221
|
+
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it('leaves approvers unchanged when there is no active delegation', async () => {
|
|
1225
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1226
|
+
expect(req.pending_approvers).toEqual(['alice']);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it('does not OOO-substitute group-routed (position) approvers', async () => {
|
|
1230
|
+
engine._tables['sys_user_position'] = [{ id: 'up1', user_id: 'alice', position: 'sales_manager', organization_id: 't1' }];
|
|
1231
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
|
|
1232
|
+
const input = {
|
|
1233
|
+
...openInput([]),
|
|
1234
|
+
config: { approvers: [{ type: 'position', value: 'sales_manager' }], behavior: 'first_response', lockRecord: true },
|
|
1235
|
+
};
|
|
1236
|
+
const req = await svc.openNodeRequest(input as any, CTX);
|
|
1237
|
+
// Position-routed leave is ADR-0091's job, not this path: the holder stays.
|
|
1238
|
+
expect(req.pending_approvers).toEqual(['alice']);
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
it('respects tenant scope: a rule scoped to another org does not apply', async () => {
|
|
1242
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: 't2' }]);
|
|
1243
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1244
|
+
expect(req.pending_approvers).toEqual(['alice']);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('applies a cross-tenant (null org) rule regardless of request tenant', async () => {
|
|
1248
|
+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: null }]);
|
|
1249
|
+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
|
|
1250
|
+
expect(req.pending_approvers).toEqual(['bob']);
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// ── Delegation self-service write guard (#1322 follow-up) ─────────────
|
|
1255
|
+
//
|
|
1256
|
+
// sys_approval_delegation is apiEnabled CRUD; a member must not be able to
|
|
1257
|
+
// forge a delegation for someone else (delegator_id = victim) and reroute the
|
|
1258
|
+
// victim's approvals. The guard forces delegator_id == acting user for normal
|
|
1259
|
+
// writes; system/admin contexts bypass. Row-ownership on update/delete is the
|
|
1260
|
+
// platform's created_by RLS (not exercised here).
|
|
1261
|
+
describe('sys_approval_delegation write guard (#1322)', () => {
|
|
1262
|
+
const DEL = 'sys_approval_delegation';
|
|
1263
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1264
|
+
|
|
1265
|
+
beforeEach(() => {
|
|
1266
|
+
engine = makeFakeEngine();
|
|
1267
|
+
bindDelegationWriteGuard(engine as any);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
const fireInsert = (data: any, session: any) =>
|
|
1271
|
+
(engine as any).fire('beforeInsert', { object: DEL, input: { data }, session });
|
|
1272
|
+
const fireUpdate = (data: any, session: any) =>
|
|
1273
|
+
(engine as any).fire('beforeUpdate', { object: DEL, input: { id: data?.id ?? 'd1', data }, session });
|
|
1274
|
+
const member = (userId?: string) => ({ isSystem: false, roles: [], ...(userId ? { userId } : {}) });
|
|
1275
|
+
|
|
1276
|
+
it('allows a member to create their own delegation', async () => {
|
|
1277
|
+
await expect(fireInsert({ delegator_id: 'u1', delegate_id: 'u2' }, member('u1'))).resolves.toBeUndefined();
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
it('rejects a member forging a delegation for someone else', async () => {
|
|
1281
|
+
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
it('stamps the caller as delegator when omitted on insert', async () => {
|
|
1285
|
+
const data: any = { delegate_id: 'u2' };
|
|
1286
|
+
await fireInsert(data, member('u1'));
|
|
1287
|
+
expect(data.delegator_id).toBe('u1');
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it('rejects an unauthenticated non-system insert', async () => {
|
|
1291
|
+
await expect(fireInsert({ delegate_id: 'u2' }, member())).rejects.toThrow(/FORBIDDEN/);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
it('bypasses the guard for system context', async () => {
|
|
1295
|
+
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u1' }, { isSystem: true })).resolves.toBeUndefined();
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it('lets an admin set the delegator to anyone', async () => {
|
|
1299
|
+
await expect(fireInsert({ delegator_id: 'victim', delegate_id: 'u2' }, { isSystem: false, roles: ['admin'], userId: 'admin1' })).resolves.toBeUndefined();
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it('rejects a member relabelling delegator on update', async () => {
|
|
1303
|
+
await expect(fireUpdate({ id: 'd1', delegator_id: 'victim' }, member('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it('allows a member update that does not touch delegator_id', async () => {
|
|
1307
|
+
await expect(fireUpdate({ id: 'd1', valid_until: '2026-06-01T00:00:00Z' }, member('u1'))).resolves.toBeUndefined();
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it('rejects a batch insert if any row names a foreign delegator', async () => {
|
|
1311
|
+
await expect(fireInsert(
|
|
1312
|
+
[{ delegator_id: 'u1', delegate_id: 'u2' }, { delegator_id: 'victim', delegate_id: 'u3' }],
|
|
1313
|
+
member('u1'),
|
|
1314
|
+
)).rejects.toThrow(/FORBIDDEN/);
|
|
1315
|
+
});
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
// ── Quorum & per-group sign-off (#3266) ───────────────────────────────
|
|
1319
|
+
//
|
|
1320
|
+
// quorum = M-of-N collective sign-off; per_group = one (or minApprovals) from
|
|
1321
|
+
// EACH group (会签). A single rejection is always a veto. Group membership is
|
|
1322
|
+
// snapshotted at open, so OOO-substituted approvers count for their group.
|
|
1323
|
+
describe('ApprovalService — quorum & per_group (#3266)', () => {
|
|
1324
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1325
|
+
let svc: ApprovalService;
|
|
1326
|
+
const base = new Date('2026-08-01T10:00:00Z').getTime();
|
|
1327
|
+
|
|
1328
|
+
beforeEach(() => {
|
|
1329
|
+
engine = makeFakeEngine();
|
|
1330
|
+
let n = 0;
|
|
1331
|
+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
// Build an openNodeRequest input with explicit approver specs + behavior.
|
|
1335
|
+
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
|
|
1336
|
+
...openInput([]),
|
|
1337
|
+
config: { approvers, behavior, lockRecord: true, ...extra },
|
|
1338
|
+
});
|
|
1339
|
+
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
|
|
1340
|
+
|
|
1341
|
+
it('quorum: holds until minApprovals reached, then finalizes', async () => {
|
|
1342
|
+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
1343
|
+
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
1344
|
+
expect(a.finalized).toBe(false);
|
|
1345
|
+
expect(a.request.status).toBe('pending');
|
|
1346
|
+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
|
|
1347
|
+
expect(b.finalized).toBe(true);
|
|
1348
|
+
expect(b.request.status).toBe('approved');
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it('quorum: minApprovals clamps to the approver count (no deadlock)', async () => {
|
|
1352
|
+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2')], 'quorum', { minApprovals: 5 }), CTX);
|
|
1353
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
1354
|
+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
|
|
1355
|
+
expect(b.finalized).toBe(true);
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
it('quorum: any reject is a veto', async () => {
|
|
1359
|
+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
1360
|
+
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u1' }, SYS);
|
|
1361
|
+
expect(r.finalized).toBe(true);
|
|
1362
|
+
expect(r.request.status).toBe('rejected');
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
it('per_group: advances only when EACH group approves', async () => {
|
|
1366
|
+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1367
|
+
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
1368
|
+
expect(a.finalized).toBe(false); // finance still pending
|
|
1369
|
+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
|
|
1370
|
+
expect(b.finalized).toBe(true);
|
|
1371
|
+
expect(b.request.status).toBe('approved');
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('per_group: two approvals in ONE group do not satisfy another group', async () => {
|
|
1375
|
+
const req = await svc.openNodeRequest(
|
|
1376
|
+
cfg([U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1377
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
1378
|
+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l2' }, SYS);
|
|
1379
|
+
expect(b.finalized).toBe(false); // finance still missing
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it('per_group: minApprovals=2 needs two from each group', async () => {
|
|
1383
|
+
const req = await svc.openNodeRequest(cfg(
|
|
1384
|
+
[U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance'), U('f2', 'finance')],
|
|
1385
|
+
'per_group', { minApprovals: 2 }), CTX);
|
|
1386
|
+
for (const u of ['l1', 'f1', 'l2']) {
|
|
1387
|
+
const r = await svc.decideNode(req.id, { decision: 'approve', actorId: u }, SYS);
|
|
1388
|
+
expect(r.finalized).toBe(false);
|
|
1389
|
+
}
|
|
1390
|
+
const done = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f2' }, SYS);
|
|
1391
|
+
expect(done.finalized).toBe(true);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it('per_group: reject is a veto', async () => {
|
|
1395
|
+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1396
|
+
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'l1' }, SYS);
|
|
1397
|
+
expect(r.request.status).toBe('rejected');
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it('per_group: an OOO-substituted member still counts for their group', async () => {
|
|
1401
|
+
engine._tables['sys_approval_delegation'] = [
|
|
1402
|
+
{ id: 'd', delegator_id: 'l1', delegate_id: 'lb', organization_id: 't1', valid_from: null, valid_until: null, reason: 'leave' },
|
|
1403
|
+
];
|
|
1404
|
+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1405
|
+
expect(req.pending_approvers).toContain('lb');
|
|
1406
|
+
expect(req.pending_approvers).not.toContain('l1');
|
|
1407
|
+
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'lb' }, SYS); // delegate covers legal
|
|
1408
|
+
expect(a.finalized).toBe(false);
|
|
1409
|
+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
|
|
1410
|
+
expect(b.finalized).toBe(true);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it('records decision attachments on the audit row', async () => {
|
|
1414
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1415
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_1', 'file_2'] }, SYS);
|
|
1416
|
+
const act = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
|
|
1417
|
+
expect(act.attachments).toEqual(['file_1', 'file_2']);
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
// ── Decision progress + notification deep links (#2678 P1.5) ──────────
|
|
1422
|
+
describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () => {
|
|
1423
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
1424
|
+
let svc: ApprovalService;
|
|
1425
|
+
|
|
1426
|
+
beforeEach(() => {
|
|
1427
|
+
engine = makeFakeEngine();
|
|
1428
|
+
let n = 0;
|
|
1429
|
+
const base = new Date('2026-09-01T09:00:00Z').getTime();
|
|
1430
|
+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
|
|
1434
|
+
...openInput([]),
|
|
1435
|
+
config: { approvers, behavior, lockRecord: true, ...extra },
|
|
1436
|
+
});
|
|
1437
|
+
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
|
|
1438
|
+
|
|
1439
|
+
it('per_group: getRequest exposes per-group progress that updates per approval', async () => {
|
|
1440
|
+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
|
|
1441
|
+
let row: any = await svc.getRequest(req.id, SYS);
|
|
1442
|
+
expect(row.decision_progress).toMatchObject({ behavior: 'per_group', got: 0, need: 2 });
|
|
1443
|
+
expect(row.decision_progress.groups).toEqual([
|
|
1444
|
+
{ group: 'finance', got: 0, need: 1, satisfied: false },
|
|
1445
|
+
{ group: 'legal', got: 0, need: 1, satisfied: false },
|
|
1446
|
+
]);
|
|
1447
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
|
|
1448
|
+
row = await svc.getRequest(req.id, SYS);
|
|
1449
|
+
expect(row.decision_progress.got).toBe(1);
|
|
1450
|
+
expect(row.decision_progress.groups.find((g: any) => g.group === 'legal')).toMatchObject({ got: 1, satisfied: true });
|
|
1451
|
+
expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it('quorum: progress reports approvals against the clamped threshold', async () => {
|
|
1455
|
+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
|
|
1456
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
|
|
1457
|
+
const row: any = await svc.getRequest(req.id, SYS);
|
|
1458
|
+
expect(row.decision_progress).toMatchObject({ behavior: 'quorum', got: 1, need: 2 });
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
it('first_response: no decision_progress', async () => {
|
|
1462
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1463
|
+
const row: any = await svc.getRequest(req.id, SYS);
|
|
1464
|
+
expect(row.decision_progress).toBeUndefined();
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it('notify: inbox actionUrl is rewritten to a request deep link', async () => {
|
|
1468
|
+
const emitted: any[] = [];
|
|
1469
|
+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
|
|
1470
|
+
const req = await svc.openNodeRequest(openInput(['u1', 'u2']), CTX);
|
|
1471
|
+
await svc.reassign(req.id, { actorId: 'u1', to: 'u7' }, SYS);
|
|
1472
|
+
const note = emitted.find(e => e.topic === 'approval.reassigned');
|
|
1473
|
+
expect(note.payload.actionUrl).toBe(`/system/approvals?request=${encodeURIComponent(req.id)}`);
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
// listActions must surface decision attachments through the contract mapping
|
|
1478
|
+
// (#3266 — the column existed but rowFromAction dropped it; caught in browser).
|
|
1479
|
+
describe('ApprovalService — listActions attachments mapping (#3266)', () => {
|
|
1480
|
+
it('returns the attachments recorded on a decision', async () => {
|
|
1481
|
+
const engine = makeFakeEngine();
|
|
1482
|
+
let n = 0;
|
|
1483
|
+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
|
|
1484
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
1485
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
|
|
1486
|
+
const acts = await svc.listActions(req.id, SYS);
|
|
1487
|
+
const approve = acts.find(a => a.action === 'approve');
|
|
1488
|
+
expect(approve?.attachments).toEqual(['file_a']);
|
|
1489
|
+
});
|
|
1490
|
+
});
|