@objectstack/plugin-approvals 16.0.0 → 17.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +1037 -0
- package/dist/index.d.mts +650 -535
- package/dist/index.d.ts +650 -535
- package/dist/index.js +1713 -207
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1711 -197
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/scripts/i18n-extract.config.ts +6 -1
- package/src/approval-actor-impersonation.test.ts +330 -0
- package/src/approval-node.test.ts +160 -0
- package/src/approval-node.ts +57 -0
- package/src/approval-revise.test.ts +41 -34
- package/src/approval-service.test.ts +1408 -40
- package/src/approval-service.ts +1364 -107
- package/src/approvals-plugin.ts +36 -5
- package/src/approver-cross-org.integration.test.ts +206 -0
- package/src/approver-org-scope.test.ts +201 -0
- package/src/approver-org-scope.ts +261 -0
- package/src/index.ts +3 -0
- package/src/lifecycle-hooks.ts +22 -0
- package/src/record-lock-schedule-run.integration.test.ts +206 -0
- package/src/status-mirror-cascade.integration.test.ts +224 -0
- package/src/sys-approval-action.object.ts +9 -0
- package/src/sys-approval-delegation.object.test.ts +42 -0
- package/src/sys-approval-delegation.object.ts +3 -3
- package/src/sys-approval-request.object.test.ts +13 -0
- package/src/sys-approval-request.object.ts +17 -5
- package/src/translations/bundle-ownership.test.ts +48 -0
- package/src/translations/en.objects.generated.ts +111 -5
- package/src/translations/es-ES.objects.generated.ts +111 -5
- package/src/translations/ja-JP.objects.generated.ts +111 -5
- package/src/translations/zh-CN.objects.generated.ts +110 -4
package/src/approvals-plugin.ts
CHANGED
|
@@ -121,6 +121,20 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
121
121
|
engine: engine as ApprovalEngine,
|
|
122
122
|
logger: ctx.logger,
|
|
123
123
|
publicBaseUrl: this.options.publicBaseUrl,
|
|
124
|
+
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
125
|
+
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
126
|
+
// service resolves its posture during its own start, which may not have
|
|
127
|
+
// run yet; an unresolvable posture reads as "unknown" and the guard
|
|
128
|
+
// stands down rather than refusing a legitimate flow on a minimal stack.
|
|
129
|
+
tenancyPosture: () => {
|
|
130
|
+
try {
|
|
131
|
+
const tenancy = ctx.getService<{ posture?: string }>('tenancy');
|
|
132
|
+
const posture = tenancy?.posture;
|
|
133
|
+
return typeof posture === 'string' && posture ? posture : undefined;
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
124
138
|
});
|
|
125
139
|
|
|
126
140
|
// Record lock: block edits to a record while it has a pending request.
|
|
@@ -161,12 +175,29 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
161
175
|
if (!jobs || typeof jobs.schedule !== 'function' || !this.service) return;
|
|
162
176
|
const svc = this.service;
|
|
163
177
|
const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
// Both sweeps ride this one clock: they walk the same `pending` set, and
|
|
179
|
+
// the dead-run release (#3456) is reconciliation with the same "catch up
|
|
180
|
+
// after a restart" requirement — a run killed BY the restart is exactly
|
|
181
|
+
// the shape no in-band handler can clean up.
|
|
182
|
+
// Genuinely independent — an escalation failure must not strand locked
|
|
183
|
+
// records, and vice versa, so neither can short-circuit the other.
|
|
184
|
+
const sweep = async () => {
|
|
185
|
+
const results = await Promise.allSettled([
|
|
186
|
+
svc.runEscalations(),
|
|
187
|
+
svc.releaseDeadRunRequests(),
|
|
188
|
+
]);
|
|
189
|
+
for (const r of results) {
|
|
190
|
+
if (r.status === 'rejected') {
|
|
191
|
+
ctx.logger.warn?.('[approvals] periodic sweep leg failed', {
|
|
192
|
+
error: (r.reason as any)?.message ?? String(r.reason),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
await jobs.schedule(ESCALATION_JOB_NAME, { type: 'interval', intervalMs }, sweep);
|
|
167
198
|
this.escalationJobScheduled = true;
|
|
168
|
-
void
|
|
169
|
-
ctx.logger.warn?.('[approvals] boot
|
|
199
|
+
void sweep().catch((err: any) => {
|
|
200
|
+
ctx.logger.warn?.('[approvals] boot sweep failed', { error: err?.message });
|
|
170
201
|
});
|
|
171
202
|
ctx.logger.info('ApprovalsServicePlugin: SLA escalation scan scheduled', { intervalMs });
|
|
172
203
|
} catch { /* job service not installed */ }
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* [ADR-0105 D9] Cross-organization approver targeting, through the SERVICE.
|
|
5
|
+
*
|
|
6
|
+
* `approver-org-scope.test.ts` owns the resolver's own rules. What only this
|
|
7
|
+
* level can show is that the resolved organization actually reaches the
|
|
8
|
+
* directory lookups — the wiring D9 is: one organization id used to decide
|
|
9
|
+
* three different things at once (where the request lives, where its inbox rows
|
|
10
|
+
* live, where its approvers are looked up), and only the third moves.
|
|
11
|
+
*
|
|
12
|
+
* The scenario is the one D9 exists for: a purchase order raised in PLANT A
|
|
13
|
+
* needs the group CFO, who holds `cfo` in the GROUP organization and would have
|
|
14
|
+
* matched nobody before.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
18
|
+
import { ApprovalService } from './approval-service.js';
|
|
19
|
+
|
|
20
|
+
interface Row { [k: string]: any }
|
|
21
|
+
|
|
22
|
+
function makeEngine(seed: Record<string, Row[]> = {}) {
|
|
23
|
+
const tables: Record<string, Row[]> = { ...seed };
|
|
24
|
+
const ensure = (n: string) => (tables[n] ??= []);
|
|
25
|
+
const matches = (row: Row, filter: any): boolean => {
|
|
26
|
+
if (!filter || typeof filter !== 'object') return true;
|
|
27
|
+
for (const [k, v] of Object.entries(filter)) {
|
|
28
|
+
if (k === '$or') {
|
|
29
|
+
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const rv = row[k];
|
|
33
|
+
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
|
|
34
|
+
if (!(v as any).$in.includes(rv)) return false;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
|
|
38
|
+
if (rv === (v as any).$ne) return false;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (rv !== v) return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
_tables: tables,
|
|
47
|
+
async find(object: string, options?: any) {
|
|
48
|
+
return ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
|
|
49
|
+
},
|
|
50
|
+
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
|
|
51
|
+
async update(_o: string, _w: any, _d: any) { return {}; },
|
|
52
|
+
async delete() { return {}; },
|
|
53
|
+
async count(object: string) { return ensure(object).length; },
|
|
54
|
+
registerHook() { /* no-op */ },
|
|
55
|
+
unregisterHooksByPackage() { /* no-op */ },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Plant A sits under the group; the CFO's position lives in the group org. */
|
|
60
|
+
const SEED = () => ({
|
|
61
|
+
sys_organization: [
|
|
62
|
+
{ id: 'o_group', slug: 'acme-group', parent_organization_id: null },
|
|
63
|
+
{ id: 'o_plant', slug: 'acme-plant-a', parent_organization_id: 'o_group' },
|
|
64
|
+
],
|
|
65
|
+
sys_user_position: [
|
|
66
|
+
{ id: 'up1', user_id: 'u_cfo', position: 'cfo', organization_id: 'o_group' },
|
|
67
|
+
{ id: 'up2', user_id: 'u_plant_mgr', position: 'plant_manager', organization_id: 'o_plant' },
|
|
68
|
+
],
|
|
69
|
+
sys_member: [
|
|
70
|
+
// The intended group shape: group staff hold a membership in every plant
|
|
71
|
+
// (so D2's union lets them READ the request) while their POSITION lives in
|
|
72
|
+
// the group organization.
|
|
73
|
+
{ id: 'm1', user_id: 'u_cfo', organization_id: 'o_plant', role: 'member' },
|
|
74
|
+
{ id: 'm2', user_id: 'u_plant_mgr', organization_id: 'o_plant', role: 'member' },
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const CTX = { userId: 'u_submitter', organizationId: 'o_plant', positions: [], permissions: [] } as any;
|
|
79
|
+
|
|
80
|
+
function openInput(approvers: any[], extra: Record<string, any> = {}) {
|
|
81
|
+
return {
|
|
82
|
+
object: 'purchase_order',
|
|
83
|
+
recordId: 'po1',
|
|
84
|
+
runId: 'run_1',
|
|
85
|
+
nodeId: 'group_signoff',
|
|
86
|
+
flowName: 'po_approval',
|
|
87
|
+
config: { approvers, behavior: 'unanimous' as const, lockRecord: false },
|
|
88
|
+
record: { id: 'po1', amount: 500000 },
|
|
89
|
+
...extra,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('ADR-0105 D9 — cross-org approver targeting through ApprovalService', () => {
|
|
94
|
+
let engine: ReturnType<typeof makeEngine>;
|
|
95
|
+
let svc: ApprovalService;
|
|
96
|
+
let n = 0;
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
engine = makeEngine(SEED());
|
|
100
|
+
n = 0;
|
|
101
|
+
svc = new ApprovalService({
|
|
102
|
+
engine: engine as any,
|
|
103
|
+
clock: { now: () => new Date(1767000000000 + (n++) * 1000) },
|
|
104
|
+
tenancyPosture: () => 'group',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('without targeting, a group position matches NOBODY — the gap D9 closes', async () => {
|
|
109
|
+
// The `cfo` position exists, but in the group org; the request is Plant A's.
|
|
110
|
+
// Pre-D9 this was the only possible outcome, and it was silent.
|
|
111
|
+
const req = await svc.openNodeRequest(openInput([{ type: 'position', value: 'cfo' }]), CTX);
|
|
112
|
+
expect(req.pending_approvers).toEqual(['position:cfo']); // the dead literal slot
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('`organization: $root` resolves the CFO against the GROUP directory', async () => {
|
|
116
|
+
const req = await svc.openNodeRequest(
|
|
117
|
+
openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
|
|
118
|
+
CTX,
|
|
119
|
+
);
|
|
120
|
+
expect(req.pending_approvers).toEqual(['u_cfo']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('the request and its inbox rows still belong to the REQUEST org — only the lookup moved', async () => {
|
|
124
|
+
await svc.openNodeRequest(
|
|
125
|
+
openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
|
|
126
|
+
CTX,
|
|
127
|
+
);
|
|
128
|
+
// This is the whole point of the split: targeting must not relocate the
|
|
129
|
+
// request into the approver's organization, or the plant would lose its own
|
|
130
|
+
// audit trail to the group.
|
|
131
|
+
expect(engine._tables['sys_approval_request'][0].organization_id).toBe('o_plant');
|
|
132
|
+
expect(engine._tables['sys_approval_approver'][0].organization_id).toBe('o_plant');
|
|
133
|
+
expect(engine._tables['sys_approval_action'][0].organization_id).toBe('o_plant');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('mixes a plant approver and a group approver in ONE node — why targeting is per-approver', async () => {
|
|
137
|
+
// A node-level declaration could not express this: the plant manager and the
|
|
138
|
+
// group CFO sign off in parallel, each resolved in a different directory.
|
|
139
|
+
const req = await svc.openNodeRequest(
|
|
140
|
+
openInput([
|
|
141
|
+
{ type: 'position', value: 'plant_manager', group: 'plant' },
|
|
142
|
+
{ type: 'position', value: 'cfo', organization: '$root', group: 'finance' },
|
|
143
|
+
]),
|
|
144
|
+
CTX,
|
|
145
|
+
);
|
|
146
|
+
expect(new Set(req.pending_approvers)).toEqual(new Set(['u_plant_mgr', 'u_cfo']));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('drops a targeted approver who could not READ the request, leaving the empty-slate path', async () => {
|
|
150
|
+
// Same flow, but the CFO holds no membership in Plant A — D2's union wall
|
|
151
|
+
// would hide the request from her. Better an empty slate (which the node has
|
|
152
|
+
// an `onEmptyApprovers` policy for) than a task she cannot open.
|
|
153
|
+
engine._tables['sys_member'] = engine._tables['sys_member'].filter((m) => m.user_id !== 'u_cfo');
|
|
154
|
+
const req = await svc.openNodeRequest(
|
|
155
|
+
openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
|
|
156
|
+
CTX,
|
|
157
|
+
);
|
|
158
|
+
expect(req.pending_approvers).toEqual(['position:cfo']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Regression: `user` / `field` / `manager` return EARLY in
|
|
162
|
+
// `resolveApproverSpec`, before the graph-expansion branch. D9's resolution
|
|
163
|
+
// originally sat after those returns, so the declaration on a directory-less
|
|
164
|
+
// type was silently INERT rather than refused — the one behaviour ADR-0105 D9
|
|
165
|
+
// and the authoring docs both promise it is not. The resolver's own unit test
|
|
166
|
+
// could not see it (it calls the resolver directly); only a request opened
|
|
167
|
+
// through the service reaches the early return. Caught by cloud's
|
|
168
|
+
// group-posture dogfood.
|
|
169
|
+
it('refuses `organization` on a directory-less type — the early return must not skip the check', async () => {
|
|
170
|
+
for (const spec of [
|
|
171
|
+
{ type: 'user', value: 'u_cfo', organization: '$root' },
|
|
172
|
+
{ type: 'field', value: 'owner_id', organization: '$root' },
|
|
173
|
+
{ type: 'manager', organization: '$root' },
|
|
174
|
+
]) {
|
|
175
|
+
await expect(
|
|
176
|
+
svc.openNodeRequest(openInput([spec]), CTX),
|
|
177
|
+
`approver type '${spec.type}' must refuse a cross-org declaration`,
|
|
178
|
+
).rejects.toThrow(/VALIDATION_FAILED.*no effect/);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('a directory-less approver with NO declaration is untouched by D9', async () => {
|
|
183
|
+
// The guard must not cost the ordinary case anything.
|
|
184
|
+
const req = await svc.openNodeRequest(openInput([{ type: 'user', value: 'u_plant_mgr' }]), CTX);
|
|
185
|
+
expect(req.pending_approvers).toEqual(['u_plant_mgr']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('refuses a target outside the group — loudly, and no request is created', async () => {
|
|
189
|
+
engine._tables['sys_organization'].push({ id: 'o_rival', slug: 'rival-co', parent_organization_id: null });
|
|
190
|
+
await expect(
|
|
191
|
+
svc.openNodeRequest(openInput([{ type: 'position', value: 'cfo', organization: 'rival-co' }]), CTX),
|
|
192
|
+
).rejects.toThrow(/VALIDATION_FAILED.*not in the same group/);
|
|
193
|
+
expect(engine._tables['sys_approval_request'] ?? []).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('refuses under a non-group posture — a posture migration cannot silently reroute', async () => {
|
|
197
|
+
const isolated = new ApprovalService({
|
|
198
|
+
engine: engine as any,
|
|
199
|
+
clock: { now: () => new Date(1767000000000) },
|
|
200
|
+
tenancyPosture: () => 'isolated',
|
|
201
|
+
});
|
|
202
|
+
await expect(
|
|
203
|
+
isolated.openNodeRequest(openInput([{ type: 'position', value: 'cfo', organization: '$root' }]), CTX),
|
|
204
|
+
).rejects.toThrow(/VALIDATION_FAILED.*'group' tenancy posture/);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* [ADR-0105 D9] Cross-organization approver targeting.
|
|
5
|
+
*
|
|
6
|
+
* The property that matters is that this is a route WITHIN one group, not a
|
|
7
|
+
* channel to an arbitrary tenant — so every test here is about a boundary
|
|
8
|
+
* holding or a failure being LOUD. The thing D9 exists to prevent is silence:
|
|
9
|
+
* an approval that reads as "escalate to group" but quietly resolves inside the
|
|
10
|
+
* plant, or one that routes to someone the D2 wall then hides the request from.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
import {
|
|
15
|
+
filterApproversWhoCanRead,
|
|
16
|
+
resolveApproverDirectoryOrg,
|
|
17
|
+
type ApproverOrgScopeDeps,
|
|
18
|
+
} from './approver-org-scope.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A three-tier group: group → division → plant, plus a shared-services SIBLING
|
|
22
|
+
* of the plant, plus an organization in a DIFFERENT group entirely.
|
|
23
|
+
*/
|
|
24
|
+
const ORGS: Record<string, { id: string; slug: string; parent_organization_id: string | null }> = {
|
|
25
|
+
o_group: { id: 'o_group', slug: 'acme-group', parent_organization_id: null },
|
|
26
|
+
o_div: { id: 'o_div', slug: 'acme-north', parent_organization_id: 'o_group' },
|
|
27
|
+
o_plant: { id: 'o_plant', slug: 'acme-plant-a', parent_organization_id: 'o_div' },
|
|
28
|
+
o_ssc: { id: 'o_ssc', slug: 'acme-ssc', parent_organization_id: 'o_group' },
|
|
29
|
+
o_other: { id: 'o_other', slug: 'rival-co', parent_organization_id: null },
|
|
30
|
+
o_lone: { id: 'o_lone', slug: 'lone-co', parent_organization_id: null },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function makeDeps(over: Partial<ApproverOrgScopeDeps> & { members?: Array<{ user_id: string; organization_id: string }> } = {}): ApproverOrgScopeDeps & { warn: any } {
|
|
34
|
+
const warn = vi.fn();
|
|
35
|
+
const members = over.members ?? [];
|
|
36
|
+
const engine = {
|
|
37
|
+
find: vi.fn(async (object: string, opts: any) => {
|
|
38
|
+
const f = opts?.filter ?? {};
|
|
39
|
+
if (object === 'sys_organization') {
|
|
40
|
+
const rows = Object.values(ORGS).filter((o) =>
|
|
41
|
+
(f.id === undefined || o.id === f.id) && (f.slug === undefined || o.slug === f.slug));
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
44
|
+
if (object === 'sys_member') {
|
|
45
|
+
const wanted: string[] = f.user_id?.$in ?? [];
|
|
46
|
+
return members.filter((m) => m.organization_id === f.organization_id && wanted.includes(m.user_id));
|
|
47
|
+
}
|
|
48
|
+
return [];
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
return { engine, logger: { warn }, posture: () => 'group', ...over, warn } as any;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const resolve = (
|
|
55
|
+
deps: ApproverOrgScopeDeps,
|
|
56
|
+
declaration: string | undefined,
|
|
57
|
+
requestOrg: string | null,
|
|
58
|
+
type = 'position',
|
|
59
|
+
orgScoped = true,
|
|
60
|
+
) => resolveApproverDirectoryOrg(deps, declaration, requestOrg, type, orgScoped);
|
|
61
|
+
|
|
62
|
+
describe('resolveApproverDirectoryOrg — the default path is untouched', () => {
|
|
63
|
+
it('returns the request org and reads NOTHING when no organization is declared', async () => {
|
|
64
|
+
const deps = makeDeps();
|
|
65
|
+
await expect(resolve(deps, undefined, 'o_plant')).resolves.toBe('o_plant');
|
|
66
|
+
// The overwhelmingly common case must not cost a query.
|
|
67
|
+
expect((deps.engine.find as any)).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('passes a null request org straight through — a single-org deployment is unaffected', async () => {
|
|
71
|
+
const deps = makeDeps();
|
|
72
|
+
await expect(resolve(deps, undefined, null)).resolves.toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('resolveApproverDirectoryOrg — symbols resolve against the D6 tree', () => {
|
|
77
|
+
it('$root climbs to the group organization, not just one level', async () => {
|
|
78
|
+
// o_plant → o_div → o_group. A one-level implementation would answer o_div.
|
|
79
|
+
await expect(resolve(makeDeps(), '$root', 'o_plant')).resolves.toBe('o_group');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('$parent stops at exactly one level — division sign-off in a three-tier group', async () => {
|
|
83
|
+
await expect(resolve(makeDeps(), '$parent', 'o_plant')).resolves.toBe('o_div');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('$root on an organization with no lineage FAILS instead of silently self-targeting', async () => {
|
|
87
|
+
// Returning o_lone would make "escalate to group" mean "approve in place" —
|
|
88
|
+
// the exact silent misrouting D9 exists to prevent.
|
|
89
|
+
await expect(resolve(makeDeps(), '$root', 'o_lone')).rejects.toThrow(
|
|
90
|
+
/VALIDATION_FAILED.*no 'parent_organization_id' lineage/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('$parent at the root of a group fails rather than resolving to nothing', async () => {
|
|
95
|
+
await expect(resolve(makeDeps(), '$parent', 'o_group')).rejects.toThrow(/VALIDATION_FAILED.*no parent/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('resolveApproverDirectoryOrg — a slug names one organization, bounded by the group', () => {
|
|
100
|
+
it('accepts a SIBLING in the same group — the shared-services-centre shape', async () => {
|
|
101
|
+
// o_ssc is not an ancestor of o_plant; it shares the root. This is why the
|
|
102
|
+
// rule is "shares a root", not "is an ancestor".
|
|
103
|
+
await expect(resolve(makeDeps(), 'acme-ssc', 'o_plant')).resolves.toBe('o_ssc');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('refuses an organization in a DIFFERENT group — this is not a channel to any tenant', async () => {
|
|
107
|
+
await expect(resolve(makeDeps(), 'rival-co', 'o_plant')).rejects.toThrow(
|
|
108
|
+
/VALIDATION_FAILED.*not in the same group/,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('refuses an unknown slug and says an id is not accepted', async () => {
|
|
113
|
+
// The most likely authoring mistake is pasting an organization id, which is
|
|
114
|
+
// per-deployment and would make the flow unportable.
|
|
115
|
+
await expect(resolve(makeDeps(), 'o_plant', 'o_plant')).rejects.toThrow(
|
|
116
|
+
/VALIDATION_FAILED.*never an id/,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('resolveApproverDirectoryOrg — the guards', () => {
|
|
122
|
+
it('refuses under a non-group posture instead of silently ignoring the declaration', async () => {
|
|
123
|
+
// A deployment migrating group → isolated must not quietly reroute its
|
|
124
|
+
// approvals; that is an audit event, not a config detail.
|
|
125
|
+
const deps = makeDeps({ posture: () => 'isolated' });
|
|
126
|
+
await expect(resolve(deps, '$root', 'o_plant')).rejects.toThrow(
|
|
127
|
+
/VALIDATION_FAILED.*requires the 'group' tenancy posture.*isolated/,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('stands down when the posture is unknown — a minimal stack is not broken by a guard it cannot answer', async () => {
|
|
132
|
+
const deps = makeDeps({ posture: () => undefined });
|
|
133
|
+
await expect(resolve(deps, '$root', 'o_plant')).resolves.toBe('o_group');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('refuses the declaration on an approver type that has no organization directory', async () => {
|
|
137
|
+
// `user` names a person outright; an `organization` on it would have no
|
|
138
|
+
// effect. An author who wrote it believed it did something.
|
|
139
|
+
await expect(resolve(makeDeps(), '$root', 'o_plant', 'user', false)).rejects.toThrow(
|
|
140
|
+
/VALIDATION_FAILED.*would have no effect/,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('refuses when the request carries no organization at all', async () => {
|
|
145
|
+
await expect(resolve(makeDeps(), '$root', null)).rejects.toThrow(
|
|
146
|
+
/VALIDATION_FAILED.*carries no organization/,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('survives a cycle in the grouping metadata instead of looping forever', async () => {
|
|
151
|
+
const deps = makeDeps();
|
|
152
|
+
(deps.engine.find as any) = vi.fn(async (object: string, opts: any) => {
|
|
153
|
+
if (object !== 'sys_organization') return [];
|
|
154
|
+
const id = opts?.filter?.id;
|
|
155
|
+
// a → b → a
|
|
156
|
+
if (id === 'a') return [{ id: 'a', slug: 'a', parent_organization_id: 'b' }];
|
|
157
|
+
if (id === 'b') return [{ id: 'b', slug: 'b', parent_organization_id: 'a' }];
|
|
158
|
+
return [];
|
|
159
|
+
});
|
|
160
|
+
// Terminates; the cycle is broken and whatever it settles on is bounded.
|
|
161
|
+
await expect(resolve(deps, '$parent', 'a')).resolves.toBe('b');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('filterApproversWhoCanRead — routing to someone the wall hides is a silent failure', () => {
|
|
166
|
+
it('keeps approvers who hold a membership in the REQUEST org', async () => {
|
|
167
|
+
const deps = makeDeps({ members: [{ user_id: 'u_cfo', organization_id: 'o_plant' }] });
|
|
168
|
+
const kept = await filterApproversWhoCanRead(deps, ['u_cfo'], 'o_plant', {
|
|
169
|
+
approverType: 'position', value: 'cfo', directoryOrgId: 'o_group',
|
|
170
|
+
});
|
|
171
|
+
expect(kept).toEqual(['u_cfo']);
|
|
172
|
+
expect(deps.warn).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('drops an approver with no membership there, and says exactly why', async () => {
|
|
176
|
+
// She holds `cfo` in the group org but no membership in the plant, so D2's
|
|
177
|
+
// union would hide the request from her: routing succeeds, then she opens a
|
|
178
|
+
// task she cannot open. Convert that into the empty-slate path the node
|
|
179
|
+
// already has a policy for.
|
|
180
|
+
const deps = makeDeps({ members: [{ user_id: 'u_ok', organization_id: 'o_plant' }] });
|
|
181
|
+
const kept = await filterApproversWhoCanRead(deps, ['u_ok', 'u_no_membership'], 'o_plant', {
|
|
182
|
+
approverType: 'position', value: 'cfo', directoryOrgId: 'o_group',
|
|
183
|
+
});
|
|
184
|
+
expect(kept).toEqual(['u_ok']);
|
|
185
|
+
expect(deps.warn).toHaveBeenCalledTimes(1);
|
|
186
|
+
const msg = String(deps.warn.mock.calls[0][0]);
|
|
187
|
+
expect(msg).toMatch(/u_no_membership|cross-organization approver/);
|
|
188
|
+
expect(msg).toMatch(/Grant those users a membership|retarget/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('does NOT empty a live slate when membership is unreadable', async () => {
|
|
192
|
+
// An infrastructure hiccup must not silently unstaff a pending approval:
|
|
193
|
+
// routing to someone who may not see it is recoverable, emptying is not.
|
|
194
|
+
const deps = makeDeps();
|
|
195
|
+
(deps.engine.find as any) = vi.fn(async () => { throw new Error('driver hiccup'); });
|
|
196
|
+
const kept = await filterApproversWhoCanRead(deps, ['u1', 'u2'], 'o_plant', {
|
|
197
|
+
approverType: 'position', directoryOrgId: 'o_group',
|
|
198
|
+
});
|
|
199
|
+
expect(kept).toEqual(['u1', 'u2']);
|
|
200
|
+
});
|
|
201
|
+
});
|