Skip to content

Commit 6fc3d10

Browse files
committed
test(plugin-approvals): pin the business-unit MEMBER org screen (#14946) — red against the unmodified service
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent 6acb37e commit 6fc3d10

1 file changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
/**
3+
* #14946 — the expanded BUSINESS-UNIT MEMBERS are screened to the directory
4+
* organization, with a STRICT equality.
5+
*
6+
* `expandBusinessUnitUsers` screens the `sys_business_unit` rows with
7+
* `businessUnitOrgScope` — null-inclusive since #3807, because a seeded unit
8+
* carries `organization_id = null` by construction and is admitted on purpose.
9+
* The `sys_business_unit_member` read that follows carried NO organization
10+
* predicate at all, under `SYSTEM_CTX`, which carries no tenant either. A
11+
* seeded unit id exists identically in every tenant, so tenant A's request
12+
* resolved that unit and then collected EVERY tenant's membership rows off it.
13+
*
14+
* Why the member screen is strict where the unit screen is not — measured on
15+
* this tree, not inherited from the sibling card:
16+
*
17+
* - `sys_business_unit_member` declares no `organization_id`
18+
* (`packages/platform-objects/src/identity/sys-business-unit-member.object.ts`);
19+
* the column is INJECTED (`applySystemFields`, `injected-system-columns.ts`)
20+
* and the tenancy census lists the object `reach: "in"` with
21+
* `tenantField: "organization_id"`;
22+
* - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`); seed
23+
* replay does not (`seed-loader.ts` withholds its `fallbackOrgId` from
24+
* every `sys_` object); elevated system-context writes do not either
25+
* (`unclassified` in `PLATFORM_OBJECT_TENANCY`, tracked as #14570).
26+
*
27+
* ⇒ a NULL there means UNKNOWN tenancy, not "platform-global", and routing
28+
* approval authority over tenant A's record to an identity of unknown
29+
* tenancy is the same cross-tenant hole by the other door. The screen fails
30+
* CLOSED, exactly as `plugin-sharing`'s `memberScope` does for the same rows.
31+
*
32+
* Every anchor unit below is SEEDED (`organization_id: null`) unless a case
33+
* says otherwise. That is load-bearing: on an org-stamped unit the assertions
34+
* would hold even if the member screen were deleted, because the unit screen
35+
* would answer first. Anchoring on a seeded unit is what makes each case a pin
36+
* on the MEMBER screen.
37+
*
38+
* B1 — THE LEAK: a seeded unit with two tenants' membership rows resolves
39+
* ONLY the request organization's users, at both depths of the walk.
40+
* B2 — THE CONTROL: an org-stamped unit still routes its own members —
41+
* strict, not broken — and still drops the other tenant's row.
42+
* B3 — THE DECLARED COST: org-less membership rows (seed replay, elevated
43+
* writes) do NOT route when the request carries an organization; the
44+
* slot falls to the literal and the #3807 warning fires, so the empty
45+
* slate is loud rather than silent.
46+
* B4 — a request carrying no organization is untouched: every member
47+
* routes and the read carries no organization predicate.
48+
* B5 — THE SHAPE: the member read carries a strict `organization_id`
49+
* equality and no `$or` null arm, in ONE read for the whole subtree.
50+
* B6 — the `expression` / `resolveAs: 'department'` call site is closed too.
51+
*/
52+
import { describe, it, expect, beforeEach } from 'vitest';
53+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
54+
import type { ApprovalRequestRow } from '@objectstack/spec/contracts';
55+
import { ApprovalService, type ApprovalNodeAutoOutcome } from './approval-service.js';
56+
57+
function makeFakeEngine() {
58+
const tables: Record<string, any[]> = {};
59+
const ensure = (n: string) => (tables[n] ??= []);
60+
function matches(row: any, filter: any): boolean {
61+
if (!filter || typeof filter !== 'object') return true;
62+
for (const [k, v] of Object.entries(filter)) {
63+
if (k === '$or') { if (!(v as any[]).some(s => matches(row, s))) return false; continue; }
64+
if (k === '$and') { if (!(v as any[]).every(s => matches(row, s))) return false; continue; }
65+
const rv = row[k];
66+
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
67+
if (!(v as any).$in.includes(rv)) return false; continue;
68+
}
69+
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
70+
if (rv === (v as any).$ne) return false; continue;
71+
}
72+
// A row that simply omits the column is `undefined`, which a strict
73+
// `organization_id: 'org_a'` equality must NOT match — the same answer
74+
// a SQL `organization_id = ?` gives an unstamped row.
75+
if (rv !== v) return false;
76+
}
77+
return true;
78+
}
79+
return {
80+
_tables: tables,
81+
/** Every `find` anyone made, with its options — pins the predicate SHAPE (B5). */
82+
_finds: [] as Array<{ object: string; options: any }>,
83+
async find(object: string, options?: any) {
84+
this._finds.push({ object, options });
85+
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
86+
return rows.slice(0, options?.limit ?? 1000);
87+
},
88+
async insert(object: string, data: any) { ensure(object).push({ ...data }); return { ...data }; },
89+
async update(object: string, data: any, options?: any) {
90+
// Pinned to ObjectQL.update's OWN dispatch predicate — a double looser
91+
// than the engine it stands in for turns a green suite into no suite.
92+
const dispatch = assertEngineUpdateDispatch(data, options);
93+
const t = ensure(object);
94+
if (dispatch.kind === 'multi') {
95+
let n = 0;
96+
for (let i = 0; i < t.length; i++) {
97+
if (matches(t[i], options?.where)) { t[i] = { ...t[i], ...data }; n++; }
98+
}
99+
return { updated: n };
100+
}
101+
const i = t.findIndex(r => r.id === dispatch.id);
102+
if (i >= 0) t[i] = { ...t[i], ...data };
103+
return t[i];
104+
},
105+
async delete(object: string, options?: any) {
106+
const dispatch = assertEngineDeleteDispatch(options);
107+
const t = ensure(object);
108+
if (dispatch.kind === 'multi') {
109+
const survivors = t.filter(r => !matches(r, options?.where));
110+
const deleted = t.length - survivors.length;
111+
t.splice(0, t.length, ...survivors);
112+
return { deleted };
113+
}
114+
const i = t.findIndex(r => r.id === dispatch.id);
115+
if (i >= 0) t.splice(i, 1);
116+
return { id: dispatch.id };
117+
},
118+
registerHook() {}, unregisterHooksByPackage() { return 0; }, async fire() {},
119+
};
120+
}
121+
122+
/** See the identical note in `team-approver-org-screen.test.ts` (#10230). */
123+
function opened(result: ApprovalRequestRow | ApprovalNodeAutoOutcome): ApprovalRequestRow {
124+
if ('autoApproved' in result) {
125+
throw new Error('expected an OPENED approval request, got an auto-approval outcome');
126+
}
127+
return result;
128+
}
129+
130+
const ORG_A = 'org_a';
131+
const ORG_B = 'org_b';
132+
const CTX_A = { userId: 'u_sub', organizationId: ORG_A, positions: [], permissions: [] } as any;
133+
/** No organization anywhere on the request — the single-org / embedded stack. */
134+
const CTX_NO_ORG = { userId: 'u_sub', positions: [], permissions: [] } as any;
135+
const DEPT_SEEDED = { type: 'department', value: 'bu_seeded' };
136+
137+
/**
138+
* The org chart the card describes: a SEEDED unit tree (`organization_id:
139+
* null`, which `businessUnitOrgScope` admits by design) — the same unit ids
140+
* in every tenant.
141+
*/
142+
const SEEDED_TREE = [
143+
{ id: 'bu_seeded', organization_id: null, active: true },
144+
{ id: 'bu_seeded_child', parent_business_unit_id: 'bu_seeded', organization_id: null, active: true },
145+
];
146+
147+
/**
148+
* Two tenants' membership rows on that shared tree, at both depths — the
149+
* shape the REST/session write path produces on a real multi-tenant
150+
* deployment, since it stamps `organization_id` from the caller's tenant.
151+
*/
152+
const TWO_TENANT_MEMBERS = [
153+
{ id: 'bm_a', business_unit_id: 'bu_seeded', user_id: 'u_a', organization_id: ORG_A },
154+
{ id: 'bm_b', business_unit_id: 'bu_seeded', user_id: 'u_b', organization_id: ORG_B },
155+
{ id: 'bm_a_child', business_unit_id: 'bu_seeded_child', user_id: 'u_a_child', organization_id: ORG_A },
156+
{ id: 'bm_b_child', business_unit_id: 'bu_seeded_child', user_id: 'u_b_child', organization_id: ORG_B },
157+
];
158+
159+
function input(approvers: any[], configExtra: Record<string, any> = {}, extra: Record<string, any> = {}) {
160+
return {
161+
object: 'opportunity', recordId: 'opp1', runId: 'run_1', nodeId: 'approve_step',
162+
flowName: 'deal_approval',
163+
config: { approvers, behavior: 'first_response' as const, lockRecord: false, ...configExtra },
164+
record: { id: 'opp1', owner_id: 'u_sub', amount: 100 },
165+
...extra,
166+
};
167+
}
168+
169+
describe('#14946 business-unit MEMBER org screen', () => {
170+
let engine: ReturnType<typeof makeFakeEngine>;
171+
let svc: ApprovalService;
172+
let warnings: Array<[any, any]>;
173+
let n = 0;
174+
175+
beforeEach(() => {
176+
engine = makeFakeEngine();
177+
warnings = [];
178+
n = 0;
179+
svc = new ApprovalService({
180+
engine: engine as any,
181+
clock: { now: () => new Date(new Date('2026-01-15T10:00:00Z').getTime() + (n++) * 1000) },
182+
logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) } as any,
183+
});
184+
engine._tables['sys_business_unit'] = SEEDED_TREE.map(r => ({ ...r }));
185+
engine._tables['sys_business_unit_member'] = TWO_TENANT_MEMBERS.map(r => ({ ...r }));
186+
});
187+
188+
const memberReads = () => engine._finds.filter(f => f.object === 'sys_business_unit_member');
189+
190+
it('B1 — THE LEAK: a seeded unit resolves ONLY the request organization\'s members, at both depths', async () => {
191+
const req = opened(await svc.openNodeRequest(input([DEPT_SEEDED]), CTX_A));
192+
console.log('[PROBE B1] org_a request, seeded unit, org_a+org_b members -> pending_approvers =',
193+
JSON.stringify(req.pending_approvers));
194+
expect([...(req.pending_approvers ?? [])].sort()).toEqual(['u_a', 'u_a_child']);
195+
expect(req.pending_approvers).not.toContain('u_b');
196+
expect(req.pending_approvers).not.toContain('u_b_child');
197+
});
198+
199+
it('B2 — THE CONTROL: an org-stamped unit still routes its own members, and still drops the other tenant\'s', async () => {
200+
// The unit screen admits this unit outright, so anything dropped here is
201+
// the MEMBER screen's doing — and anything kept proves it is strict, not
202+
// "refuses everything".
203+
engine._tables['sys_business_unit'] = [{ id: 'bu_mine', organization_id: ORG_A, active: true }];
204+
engine._tables['sys_business_unit_member'] = [
205+
{ id: 'bm1', business_unit_id: 'bu_mine', user_id: 'u_a', organization_id: ORG_A },
206+
{ id: 'bm2', business_unit_id: 'bu_mine', user_id: 'u_b', organization_id: ORG_B },
207+
];
208+
const req = opened(await svc.openNodeRequest(input([{ type: 'department', value: 'bu_mine' }]), CTX_A));
209+
console.log('[PROBE B2] org_a request, org_a unit, org_a+org_b members -> pending_approvers =',
210+
JSON.stringify(req.pending_approvers));
211+
expect(req.pending_approvers).toEqual(['u_a']);
212+
});
213+
214+
it('B3 — THE DECLARED COST: org-less membership rows do NOT route when the request carries an organization, and the empty slate is LOUD', async () => {
215+
// Seed replay and elevated system writes both leave `organization_id`
216+
// NULL (#14570). Unknown tenancy is not "this organization": the slot
217+
// falls to the literal, which #3807's warning already reports.
218+
engine._tables['sys_business_unit_member'] = [
219+
{ id: 'bm_seeded', business_unit_id: 'bu_seeded', user_id: 'u_seeded', organization_id: null },
220+
{ id: 'bm_unstamped', business_unit_id: 'bu_seeded_child', user_id: 'u_unstamped' },
221+
];
222+
const req = opened(await svc.openNodeRequest(input([DEPT_SEEDED]), CTX_A));
223+
console.log('[PROBE B3] org_a request, seeded unit, org-less members -> pending_approvers =',
224+
JSON.stringify(req.pending_approvers));
225+
expect(req.pending_approvers).toEqual(['department:bu_seeded']);
226+
const loud = warnings.find(([msg]) => String(msg).includes("approver 'department:bu_seeded' expanded to nobody"));
227+
expect(loud).toBeDefined();
228+
expect(loud?.[1]).toMatchObject({ type: 'department', value: 'bu_seeded', organizationId: ORG_A });
229+
});
230+
231+
it('B4 — a request carrying no organization is untouched: every member routes, no organization predicate', async () => {
232+
const req = opened(await svc.openNodeRequest(input([DEPT_SEEDED]), CTX_NO_ORG));
233+
console.log('[PROBE B4] org-less request -> pending_approvers =', JSON.stringify(req.pending_approvers));
234+
expect([...(req.pending_approvers ?? [])].sort()).toEqual(['u_a', 'u_a_child', 'u_b', 'u_b_child']);
235+
const reads = memberReads();
236+
expect(reads.length).toBe(1);
237+
const where = reads[0].options?.where ?? reads[0].options?.filter ?? {};
238+
expect(where).not.toHaveProperty('organization_id');
239+
expect(where).not.toHaveProperty('$or');
240+
});
241+
242+
it('B5 — THE SHAPE: one member read for the whole subtree, carrying a strict equality and no null arm', async () => {
243+
await svc.openNodeRequest(input([DEPT_SEEDED]), CTX_A);
244+
const reads = memberReads();
245+
expect(reads.length).toBe(1);
246+
const where = reads[0].options?.where ?? reads[0].options?.filter ?? {};
247+
console.log('[PROBE B5] sys_business_unit_member where =', JSON.stringify(where));
248+
expect(where.organization_id).toBe(ORG_A);
249+
// ⛔ Not `businessUnitOrgScope`'s `$or: [{organization_id}, {organization_id: null}]`.
250+
// A null arm here would re-admit every org-less row — the B3 population —
251+
// and re-open the hole for the elevated-write case.
252+
expect(where).not.toHaveProperty('$or');
253+
expect([...(where.business_unit_id?.$in ?? [])].sort()).toEqual(['bu_seeded', 'bu_seeded_child']);
254+
});
255+
256+
it('B6 — the `expression` / `resolveAs: \'department\'` call site is closed too', async () => {
257+
const req = opened(await svc.openNodeRequest(input(
258+
[{ type: 'expression', value: 'vars.picked', resolveAs: 'department' }],
259+
{ behavior: 'unanimous' },
260+
{ variables: { picked: ['bu_seeded'] } },
261+
), CTX_A));
262+
console.log('[PROBE B6] expression/resolveAs department -> pending_approvers =',
263+
JSON.stringify(req.pending_approvers));
264+
expect([...(req.pending_approvers ?? [])].sort()).toEqual(['u_a', 'u_a_child']);
265+
expect(req.pending_approvers).not.toContain('u_b');
266+
expect(req.pending_approvers).not.toContain('u_b_child');
267+
});
268+
});

0 commit comments

Comments
 (0)