@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "17.0.0-rc.
|
|
3
|
+
"version": "17.0.0-rc.2",
|
|
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,19 +13,20 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "17.0.0-rc.
|
|
17
|
-
"@objectstack/formula": "17.0.0-rc.
|
|
18
|
-
"@objectstack/
|
|
19
|
-
"@objectstack/
|
|
20
|
-
"@objectstack/
|
|
16
|
+
"@objectstack/core": "17.0.0-rc.2",
|
|
17
|
+
"@objectstack/formula": "17.0.0-rc.2",
|
|
18
|
+
"@objectstack/metadata-core": "17.0.0-rc.2",
|
|
19
|
+
"@objectstack/platform-objects": "17.0.0-rc.2",
|
|
20
|
+
"@objectstack/spec": "17.0.0-rc.2",
|
|
21
|
+
"@objectstack/types": "17.0.0-rc.2"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
|
-
"@types/node": "^26.1.
|
|
24
|
+
"@types/node": "^26.1.2",
|
|
24
25
|
"typescript": "^6.0.3",
|
|
25
26
|
"vitest": "^4.1.10",
|
|
26
|
-
"@objectstack/objectql": "17.0.0-rc.
|
|
27
|
-
"@objectstack/
|
|
28
|
-
"@objectstack/
|
|
27
|
+
"@objectstack/objectql": "17.0.0-rc.2",
|
|
28
|
+
"@objectstack/service-automation": "17.0.0-rc.2",
|
|
29
|
+
"@objectstack/trigger-record-change": "17.0.0-rc.2"
|
|
29
30
|
},
|
|
30
31
|
"keywords": [
|
|
31
32
|
"objectstack",
|
|
@@ -34,8 +35,14 @@
|
|
|
34
35
|
"workflow",
|
|
35
36
|
"process"
|
|
36
37
|
],
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
37
43
|
"scripts": {
|
|
38
44
|
"build": "tsup --config ../../../tsup.config.ts",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
39
46
|
"test": "vitest run --passWithNoTests"
|
|
40
47
|
}
|
|
41
48
|
}
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @objectstack/plugin-approvals@17.0.0-rc.0 build /home/runner/work/objectstack/objectstack/packages/plugins/plugin-approvals
|
|
3
|
-
> tsup --config ../../../tsup.config.ts
|
|
4
|
-
|
|
5
|
-
[34mCLI[39m Building entry: src/index.ts
|
|
6
|
-
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
7
|
-
[34mCLI[39m tsup v8.5.1
|
|
8
|
-
[34mCLI[39m Using tsup config: /home/runner/work/objectstack/objectstack/tsup.config.ts
|
|
9
|
-
[34mCLI[39m Target: es2020
|
|
10
|
-
[34mCLI[39m Cleaning output folder
|
|
11
|
-
[34mESM[39m Build start
|
|
12
|
-
[34mCJS[39m Build start
|
|
13
|
-
[32mESM[39m [1mdist/index.mjs [22m[32m221.37 KB[39m
|
|
14
|
-
[32mESM[39m [1mdist/index.mjs.map [22m[32m393.68 KB[39m
|
|
15
|
-
[32mESM[39m ⚡️ Build success in 287ms
|
|
16
|
-
[32mCJS[39m [1mdist/index.js [22m[32m223.23 KB[39m
|
|
17
|
-
[32mCJS[39m [1mdist/index.js.map [22m[32m394.99 KB[39m
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in 297ms
|
|
19
|
-
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in 7923ms
|
|
21
|
-
[32mDTS[39m [1mdist/index.d.mts [22m[32m434.23 KB[39m
|
|
22
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[32m434.23 KB[39m
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Build-time only config for `os i18n extract` (ADR-0029 D8). Not deployed.
|
|
5
|
-
* The plugin owns the i18n extraction for the objects it owns; the
|
|
6
|
-
* `translations` baseline is this plugin's OWN generated bundles so re-running
|
|
7
|
-
* `--merge` preserves every hand-translated string. (Initial zh-CN/ja-JP/es-ES
|
|
8
|
-
* strings were seeded from @objectstack/platform-objects.)
|
|
9
|
-
*
|
|
10
|
-
* os i18n extract packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts \
|
|
11
|
-
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
|
|
12
|
-
* --out=packages/plugins/plugin-approvals/src/translations
|
|
13
|
-
*
|
|
14
|
-
* `--no-metadata-forms` because the Studio metadata-form baseline is owned by
|
|
15
|
-
* `@objectstack/platform-objects` — this plugin translates only the objects it
|
|
16
|
-
* owns, so it must not commit a second copy. Add `--check` to run the same
|
|
17
|
-
* command as a drift gate.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { defineStack } from '@objectstack/spec';
|
|
21
|
-
import { SysApprovalRequest } from '../src/sys-approval-request.object.js';
|
|
22
|
-
import { SysApprovalAction } from '../src/sys-approval-action.object.js';
|
|
23
|
-
import { SysApprovalDelegation } from '../src/sys-approval-delegation.object.js';
|
|
24
|
-
import { enObjects } from '../src/translations/en.objects.generated.js';
|
|
25
|
-
import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
|
|
26
|
-
import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js';
|
|
27
|
-
import { esESObjects } from '../src/translations/es-ES.objects.generated.js';
|
|
28
|
-
|
|
29
|
-
export default defineStack({
|
|
30
|
-
name: 'plugin-approvals-i18n-extract',
|
|
31
|
-
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalDelegation] as any,
|
|
32
|
-
translations: [
|
|
33
|
-
{ en: { objects: enObjects } },
|
|
34
|
-
{ 'zh-CN': { objects: zhCNObjects } },
|
|
35
|
-
{ 'ja-JP': { objects: jaJPObjects } },
|
|
36
|
-
{ 'es-ES': { objects: esESObjects } },
|
|
37
|
-
],
|
|
38
|
-
});
|
package/src/action-link-pages.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Session-less HTML for the actionable-link confirm/result pages (ADR-0043).
|
|
5
|
-
*
|
|
6
|
-
* Deliberately tiny and dependency-free: these pages are reached from an
|
|
7
|
-
* email or IM message by a bearer with no session, so they must not assume
|
|
8
|
-
* the Console bundle, auth state, or client-side i18n. Static bilingual
|
|
9
|
-
* (EN / 中文) copy keeps them readable for the demo audience without a
|
|
10
|
-
* locale negotiation step.
|
|
11
|
-
*
|
|
12
|
-
* The GET page NEVER mutates — the decision happens only on the POST form
|
|
13
|
-
* submit (mail-gateway link prefetchers must not approve requests).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { ApprovalRequestRow, ApprovalActionKind } from '@objectstack/spec/contracts';
|
|
17
|
-
|
|
18
|
-
function esc(s: unknown): string {
|
|
19
|
-
return String(s ?? '')
|
|
20
|
-
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
21
|
-
.replaceAll('"', '"').replaceAll("'", ''');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function shell(title: string, body: string): string {
|
|
25
|
-
return `<!doctype html>
|
|
26
|
-
<html lang="en"><head><meta charset="utf-8">
|
|
27
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
28
|
-
<meta name="robots" content="noindex">
|
|
29
|
-
<title>${esc(title)}</title>
|
|
30
|
-
<style>
|
|
31
|
-
body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
32
|
-
background:#f6f7f9;color:#1a202c;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
33
|
-
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;max-width:440px;width:calc(100% - 32px);
|
|
34
|
-
padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
|
35
|
-
h1{font-size:18px;margin:0 0 4px}
|
|
36
|
-
.sub{color:#64748b;font-size:13px;margin:0 0 20px}
|
|
37
|
-
.row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:14px}
|
|
38
|
-
.row b{font-weight:600;text-align:right}
|
|
39
|
-
.k{color:#64748b}
|
|
40
|
-
.actions{margin-top:22px;display:flex;gap:10px}
|
|
41
|
-
button{flex:1;padding:10px 16px;border-radius:8px;border:1px solid transparent;font-size:15px;font-weight:600;cursor:pointer}
|
|
42
|
-
.approve{background:#059669;color:#fff}
|
|
43
|
-
.reject{background:#fff;color:#dc2626;border-color:#fca5a5}
|
|
44
|
-
.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-bottom:14px}
|
|
45
|
-
.ok{background:#ecfdf5;color:#047857}.warn{background:#fffbeb;color:#b45309}.err{background:#fef2f2;color:#b91c1c}
|
|
46
|
-
a{color:#2563eb;text-decoration:none}
|
|
47
|
-
.foot{margin-top:18px;font-size:12px;color:#94a3b8}
|
|
48
|
-
</style></head><body><div class="card">${body}</div></body></html>`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function summaryRows(req: ApprovalRequestRow): string {
|
|
52
|
-
const rows: Array<[string, string]> = [
|
|
53
|
-
['Process · 流程', req.process_label || req.process_name],
|
|
54
|
-
['Step · 步骤', req.step_label || req.current_step || '—'],
|
|
55
|
-
['Record · 记录', req.record_title || req.record_id],
|
|
56
|
-
['Object · 对象', req.object_label || req.object_name],
|
|
57
|
-
['Requester · 申请人', req.submitter_name || req.submitter_id || '—'],
|
|
58
|
-
];
|
|
59
|
-
return rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><b>${esc(v)}</b></div>`).join('');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** GET page: summary + a POST form. Rendering only — no mutation. */
|
|
63
|
-
export function renderConfirmPage(input: {
|
|
64
|
-
request: ApprovalRequestRow;
|
|
65
|
-
action: Extract<ApprovalActionKind, 'approve' | 'reject'>;
|
|
66
|
-
approverId: string;
|
|
67
|
-
token: string;
|
|
68
|
-
actPath: string;
|
|
69
|
-
}): string {
|
|
70
|
-
const approving = input.action === 'approve';
|
|
71
|
-
const verb = approving ? 'Approve · 通过' : 'Reject · 拒绝';
|
|
72
|
-
return shell(`${verb} — Approval`, `
|
|
73
|
-
<h1>${approving ? '✅ Approve this request?' : '⛔ Reject this request?'}</h1>
|
|
74
|
-
<p class="sub">${approving ? '确认通过该审批请求?' : '确认拒绝该审批请求?'}
|
|
75
|
-
Acting as · 操作身份:<b>${esc(input.approverId)}</b></p>
|
|
76
|
-
${summaryRows(input.request)}
|
|
77
|
-
<form method="post" action="${esc(input.actPath)}" class="actions">
|
|
78
|
-
<input type="hidden" name="token" value="${esc(input.token)}">
|
|
79
|
-
<button type="submit" class="${approving ? 'approve' : 'reject'}">${verb}</button>
|
|
80
|
-
</form>
|
|
81
|
-
<p class="foot">This link is single-use and expires automatically. · 此链接一次有效,过期自动失效。</p>`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const RESULT_COPY: Record<string, { cls: string; title: string; body: string }> = {
|
|
85
|
-
approved: { cls: 'ok', title: '✅ Approved · 已通过', body: 'The decision was recorded. · 审批结果已记录。' },
|
|
86
|
-
rejected: { cls: 'ok', title: '⛔ Rejected · 已拒绝', body: 'The decision was recorded. · 审批结果已记录。' },
|
|
87
|
-
invalid: { cls: 'err', title: 'Invalid link · 链接无效', body: 'This link is not recognized. · 无法识别该链接。' },
|
|
88
|
-
expired: { cls: 'warn', title: 'Link expired · 链接已过期', body: 'Ask the requester to send a new reminder. · 请让申请人重新发送催办。' },
|
|
89
|
-
consumed: { cls: 'warn', title: 'Already used · 链接已使用', body: 'This link was already used once. · 该链接已被使用过。' },
|
|
90
|
-
not_pending: { cls: 'warn', title: 'Already decided · 请求已处理', body: 'This request is no longer pending. · 该请求已不在待审批状态。' },
|
|
91
|
-
not_approver: { cls: 'warn', title: 'No longer your approval · 已不在你名下', body: 'This approval was handed to someone else. · 该审批已转由他人处理。' },
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
/** Terminal page for every redemption outcome (and stale GETs). */
|
|
95
|
-
export function renderResultPage(kind: keyof typeof RESULT_COPY, request?: ApprovalRequestRow): string {
|
|
96
|
-
const copy = RESULT_COPY[kind] ?? RESULT_COPY.invalid;
|
|
97
|
-
return shell(copy.title, `
|
|
98
|
-
<span class="badge ${copy.cls}">${esc(copy.title)}</span>
|
|
99
|
-
${request ? summaryRows(request) : ''}
|
|
100
|
-
<p>${esc(copy.body)}</p>
|
|
101
|
-
<p class="foot"><a href="/system/approvals">Open the Approvals Inbox · 打开审批中心</a></p>`);
|
|
102
|
-
}
|
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The acting identity on an approval is the AUTHENTICATED CALLER, never a
|
|
5
|
-
* request-body field.
|
|
6
|
-
*
|
|
7
|
-
* Every mutating entrypoint on the service takes an `actorId` and — before this
|
|
8
|
-
* suite — authorized *that value* rather than the caller behind it. The REST
|
|
9
|
-
* routes fill it from `body.actorId ?? body.actor_id ?? context.userId`, so the
|
|
10
|
-
* body won. An authenticated user could therefore name any pending approver and
|
|
11
|
-
* have that approver's decision recorded, finalized, and the owning flow resumed
|
|
12
|
-
* — or name a request's submitter and recall it.
|
|
13
|
-
*
|
|
14
|
-
* #3783 drew exactly this line for the *data-write* identity (see the
|
|
15
|
-
* `actingUserId` docblock in the service) and left the authorization side
|
|
16
|
-
* body-driven, calling a mislabelled audit row "tolerable". It is not merely a
|
|
17
|
-
* label: `pending_approvers.includes(input.actorId)` is the authorization gate
|
|
18
|
-
* itself, so naming someone else does not just misattribute the row — it is how
|
|
19
|
-
* you get through the door.
|
|
20
|
-
*
|
|
21
|
-
* The tests below are all "mallory is logged in, names someone else". Each one
|
|
22
|
-
* must be FORBIDDEN. The final block is the load-bearing negative: the two
|
|
23
|
-
* legitimate callers that supply an actor with no session behind them — the SLA
|
|
24
|
-
* sweep (a reserved sentinel) and the ADR-0043 action link (a single-use token
|
|
25
|
-
* cryptographically bound to one approver) — must keep working, or the fix has
|
|
26
|
-
* simply broken the feature instead of securing it.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
30
|
-
import { ApprovalService, SLA_ACTOR_ID } from './approval-service.js';
|
|
31
|
-
|
|
32
|
-
interface FakeRow { [k: string]: any }
|
|
33
|
-
|
|
34
|
-
/** Equality/`$in`/`$ne`/`$contains` WHERE matcher — mirrors approval-service.test.ts. */
|
|
35
|
-
function makeFakeEngine() {
|
|
36
|
-
const tables: Record<string, FakeRow[]> = {};
|
|
37
|
-
const ensure = (n: string) => (tables[n] ??= []);
|
|
38
|
-
|
|
39
|
-
function matches(row: FakeRow, filter: any): boolean {
|
|
40
|
-
if (!filter || typeof filter !== 'object') return true;
|
|
41
|
-
for (const [k, v] of Object.entries(filter)) {
|
|
42
|
-
if (k === '$or') {
|
|
43
|
-
if (!(v as any[]).some(sub => matches(row, sub))) return false;
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
const rv = row[k];
|
|
47
|
-
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
|
|
48
|
-
if (!(v as any).$in.includes(rv)) return false;
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
|
|
52
|
-
if (rv === (v as any).$ne) return false;
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
if (v != null && typeof v === 'object' && '$contains' in (v as any)) {
|
|
56
|
-
if (!String(rv ?? '').includes(String((v as any).$contains))) return false;
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (rv !== v) return false;
|
|
60
|
-
}
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
_tables: tables,
|
|
66
|
-
async find(object: string, options?: any) {
|
|
67
|
-
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
|
|
68
|
-
if (options?.orderBy?.[0]) {
|
|
69
|
-
const { field, order } = options.orderBy[0];
|
|
70
|
-
rows.sort((a, b) => {
|
|
71
|
-
const av = a[field]; const bv = b[field];
|
|
72
|
-
if (av === bv) return 0;
|
|
73
|
-
const cmp = av > bv ? 1 : -1;
|
|
74
|
-
return order === 'desc' ? -cmp : cmp;
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
const start = options?.offset ?? 0;
|
|
78
|
-
return rows.slice(start, start + (options?.limit ?? 1000));
|
|
79
|
-
},
|
|
80
|
-
async insert(object: string, data: any) {
|
|
81
|
-
ensure(object).push({ ...data });
|
|
82
|
-
return { ...data };
|
|
83
|
-
},
|
|
84
|
-
async update(object: string, idOrData: any, _opts?: any) {
|
|
85
|
-
const data = typeof idOrData === 'object' ? idOrData : _opts;
|
|
86
|
-
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
|
|
87
|
-
const table = ensure(object);
|
|
88
|
-
const i = table.findIndex(r => r.id === id);
|
|
89
|
-
if (i >= 0) table[i] = { ...table[i], ...data };
|
|
90
|
-
return table[i];
|
|
91
|
-
},
|
|
92
|
-
async delete(object: string, options?: any) {
|
|
93
|
-
const table = ensure(object);
|
|
94
|
-
const id = options?.where?.id ?? options?.id;
|
|
95
|
-
const i = table.findIndex(r => r.id === id);
|
|
96
|
-
if (i >= 0) table.splice(i, 1);
|
|
97
|
-
return { id };
|
|
98
|
-
},
|
|
99
|
-
registerHook() {},
|
|
100
|
-
unregisterHooksByPackage() { return 0; },
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** The submitter, and the approver whose slot is up for grabs. */
|
|
105
|
-
const SUBMITTER = 'alice';
|
|
106
|
-
const APPROVER = 'bob';
|
|
107
|
-
/** Authenticated, ordinary, and on neither side of the request. */
|
|
108
|
-
const MALLORY = { userId: 'mallory', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
109
|
-
const ALICE = { userId: SUBMITTER, tenantId: 't1', positions: [], permissions: [] } as any;
|
|
110
|
-
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
|
|
111
|
-
|
|
112
|
-
function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
|
|
113
|
-
return {
|
|
114
|
-
approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
|
|
115
|
-
behavior: 'first_response' as const,
|
|
116
|
-
...extra,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function openInput(approvers: string[], extra: Record<string, any> = {}, configExtra: Record<string, any> = {}) {
|
|
121
|
-
return {
|
|
122
|
-
object: 'opportunity',
|
|
123
|
-
recordId: 'opp1',
|
|
124
|
-
runId: 'run_1',
|
|
125
|
-
nodeId: 'approve_step',
|
|
126
|
-
flowName: 'deal_approval',
|
|
127
|
-
config: nodeConfig(approvers, configExtra),
|
|
128
|
-
record: { id: 'opp1', amount: 100 },
|
|
129
|
-
...extra,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Enough automation surface for the send-back path's ADR-0044 guards to pass,
|
|
135
|
-
* so a `sendBack` / `resubmit` test fails on the ACTOR check or not at all —
|
|
136
|
-
* never on a missing `revise` out-edge.
|
|
137
|
-
*/
|
|
138
|
-
function makeAutomationStub() {
|
|
139
|
-
const resumed: any[] = [];
|
|
140
|
-
const cancelled: string[] = [];
|
|
141
|
-
return {
|
|
142
|
-
resumed,
|
|
143
|
-
cancelled,
|
|
144
|
-
async getFlow() {
|
|
145
|
-
return {
|
|
146
|
-
name: 'deal_approval',
|
|
147
|
-
nodes: [{ id: 'approve_step', type: 'approval' }, { id: 'wait_revision', type: 'wait' }],
|
|
148
|
-
edges: [
|
|
149
|
-
{ id: 'e1', source: 'approve_step', target: 'ok', label: 'approve' },
|
|
150
|
-
{ id: 'e2', source: 'approve_step', target: 'no', label: 'reject' },
|
|
151
|
-
{ id: 'e3', source: 'approve_step', target: 'wait_revision', label: 'revise' },
|
|
152
|
-
{ id: 'e4', source: 'wait_revision', target: 'approve_step', label: 'resubmit', type: 'back' },
|
|
153
|
-
],
|
|
154
|
-
};
|
|
155
|
-
},
|
|
156
|
-
async resume(runId: string, signal: any) { resumed.push({ runId, signal }); },
|
|
157
|
-
async cancelRun(runId: string) { cancelled.push(runId); },
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
describe('approvals: the actor is the authenticated caller, not a body field', () => {
|
|
162
|
-
let engine: ReturnType<typeof makeFakeEngine>;
|
|
163
|
-
let svc: ApprovalService;
|
|
164
|
-
let n = 0;
|
|
165
|
-
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
|
|
166
|
-
|
|
167
|
-
beforeEach(() => {
|
|
168
|
-
engine = makeFakeEngine();
|
|
169
|
-
n = 0;
|
|
170
|
-
svc = new ApprovalService({
|
|
171
|
-
engine: engine as any,
|
|
172
|
-
clock: { now: () => new Date(baseTime + (n++) * 1000) },
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
/** Open a request submitted by alice and pending on bob. */
|
|
177
|
-
const open = (approvers = [APPROVER], configExtra: Record<string, any> = {}) =>
|
|
178
|
-
svc.openNodeRequest(openInput(approvers, {}, configExtra), ALICE);
|
|
179
|
-
|
|
180
|
-
// ── the decision itself ─────────────────────────────────────────
|
|
181
|
-
|
|
182
|
-
it('decideNode: mallory cannot approve by naming the pending approver', async () => {
|
|
183
|
-
const req = await open();
|
|
184
|
-
await expect(
|
|
185
|
-
svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
|
|
186
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('decideNode: a refused impersonation writes no audit row and leaves the request pending', async () => {
|
|
190
|
-
const req = await open();
|
|
191
|
-
await expect(
|
|
192
|
-
svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
|
|
193
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
194
|
-
|
|
195
|
-
const decisions = (engine._tables['sys_approval_action'] ?? [])
|
|
196
|
-
.filter((a: any) => a.action === 'approve' || a.action === 'reject');
|
|
197
|
-
expect(decisions).toHaveLength(0);
|
|
198
|
-
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('decideNode: mallory cannot reject by naming the pending approver', async () => {
|
|
202
|
-
const req = await open();
|
|
203
|
-
await expect(
|
|
204
|
-
svc.decideNode(req.id, { decision: 'reject', actorId: APPROVER, comment: 'no' }, MALLORY),
|
|
205
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('decide: the flow is not resumed by an impersonated decision', async () => {
|
|
209
|
-
const resumed: any[] = [];
|
|
210
|
-
svc.attachAutomation({ async resume(runId: string, signal: any) { resumed.push({ runId, signal }); } } as any);
|
|
211
|
-
const req = await open();
|
|
212
|
-
await expect(
|
|
213
|
-
svc.decide(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
|
|
214
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
215
|
-
expect(resumed).toHaveLength(0);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('decideNode: a unanimous slate cannot be filled by one user naming the others', async () => {
|
|
219
|
-
const req = await open(['bob', 'carol'], { behavior: 'unanimous' });
|
|
220
|
-
const asBob = { userId: 'bob', tenantId: 't1', positions: [], permissions: [] } as any;
|
|
221
|
-
const first = await svc.decideNode(req.id, { decision: 'approve', actorId: 'bob' }, asBob);
|
|
222
|
-
expect(first.finalized).toBe(false);
|
|
223
|
-
// Bob holds a slot, so he clears the "is a pending approver" gate — but the
|
|
224
|
-
// slot he clears it with is his own, not carol's.
|
|
225
|
-
await expect(
|
|
226
|
-
svc.decideNode(req.id, { decision: 'approve', actorId: 'carol' }, asBob),
|
|
227
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
228
|
-
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
// ── the submitter-only moves ────────────────────────────────────
|
|
232
|
-
|
|
233
|
-
it('recall: mallory cannot withdraw the request by naming its submitter', async () => {
|
|
234
|
-
const req = await open();
|
|
235
|
-
await expect(
|
|
236
|
-
svc.recall(req.id, { actorId: SUBMITTER, comment: 'gone' }, MALLORY),
|
|
237
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
238
|
-
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('resubmit: mallory cannot resubmit a returned request by naming its submitter', async () => {
|
|
242
|
-
svc.attachAutomation(makeAutomationStub() as any);
|
|
243
|
-
const req = await open();
|
|
244
|
-
const asBob = { userId: APPROVER, tenantId: 't1', positions: [], permissions: [] } as any;
|
|
245
|
-
await svc.sendBack(req.id, { actorId: APPROVER, comment: 'fix it' }, asBob);
|
|
246
|
-
expect(engine._tables['sys_approval_request'][0].status).toBe('returned');
|
|
247
|
-
await expect(
|
|
248
|
-
svc.resubmit(req.id, { actorId: SUBMITTER, comment: 'done' }, MALLORY),
|
|
249
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it('sendBack: mallory cannot return the request by naming the pending approver', async () => {
|
|
253
|
-
const req = await open();
|
|
254
|
-
await expect(
|
|
255
|
-
svc.sendBack(req.id, { actorId: APPROVER, comment: 'revise' }, MALLORY),
|
|
256
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
257
|
-
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
// ── the thread moves ────────────────────────────────────────────
|
|
261
|
-
|
|
262
|
-
it('reassign: mallory cannot move the slot by naming its holder', async () => {
|
|
263
|
-
const req = await open();
|
|
264
|
-
await expect(
|
|
265
|
-
svc.reassign(req.id, { actorId: APPROVER, to: 'mallory' }, MALLORY),
|
|
266
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
267
|
-
expect(engine._tables['sys_approval_request'][0].pending_approvers).toBe(APPROVER);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('requestInfo: mallory cannot post as the pending approver', async () => {
|
|
271
|
-
const req = await open();
|
|
272
|
-
await expect(
|
|
273
|
-
svc.requestInfo(req.id, { actorId: APPROVER, comment: 'send the contract' }, MALLORY),
|
|
274
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it('comment: mallory cannot post to the thread as the submitter', async () => {
|
|
278
|
-
const req = await open();
|
|
279
|
-
await expect(
|
|
280
|
-
svc.comment(req.id, { actorId: SUBMITTER, comment: 'looks fine to me' }, MALLORY),
|
|
281
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('remind: mallory cannot nudge as the submitter', async () => {
|
|
285
|
-
const req = await open();
|
|
286
|
-
await expect(
|
|
287
|
-
svc.remind(req.id, { actorId: SUBMITTER }, MALLORY),
|
|
288
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
// ── the legitimate paths must survive ───────────────────────────
|
|
292
|
-
//
|
|
293
|
-
// Without these, "reject every actorId that isn't the caller" would pass the
|
|
294
|
-
// suite above by breaking the SLA sweep and the emailed action link — the two
|
|
295
|
-
// callers that hold a trustworthy actor with no session behind it.
|
|
296
|
-
|
|
297
|
-
it('the real approver still decides their own slot', async () => {
|
|
298
|
-
const req = await open();
|
|
299
|
-
const asBob = { userId: APPROVER, tenantId: 't1', positions: [], permissions: [] } as any;
|
|
300
|
-
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, asBob);
|
|
301
|
-
expect(out.finalized).toBe(true);
|
|
302
|
-
expect(out.request.status).toBe('approved');
|
|
303
|
-
const row = (engine._tables['sys_approval_action'] ?? []).find((a: any) => a.action === 'approve');
|
|
304
|
-
expect(row.actor_id).toBe(APPROVER);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it('the real submitter still recalls their own request', async () => {
|
|
308
|
-
const req = await open();
|
|
309
|
-
const out = await svc.recall(req.id, { actorId: SUBMITTER }, ALICE);
|
|
310
|
-
expect(out.request.status).toBe('recalled');
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it('a system context may still name an actor with no session behind it (SLA sweep)', async () => {
|
|
314
|
-
const req = await open();
|
|
315
|
-
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: SLA_ACTOR_ID }, SYS);
|
|
316
|
-
expect(out.finalized).toBe(true);
|
|
317
|
-
const row = (engine._tables['sys_approval_action'] ?? []).find((a: any) => a.action === 'approve');
|
|
318
|
-
expect(row.actor_id).toBe(SLA_ACTOR_ID);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it('a privileged admin may still override a stuck request', async () => {
|
|
322
|
-
const req = await open(['position:cfo']);
|
|
323
|
-
const admin = {
|
|
324
|
-
userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'],
|
|
325
|
-
} as any;
|
|
326
|
-
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'root' }, admin);
|
|
327
|
-
expect(out.finalized).toBe(true);
|
|
328
|
-
expect(out.request.status).toBe('approved');
|
|
329
|
-
});
|
|
330
|
-
});
|