diff --git a/.changeset/team-member-org-screen.md b/.changeset/team-member-org-screen.md new file mode 100644 index 0000000000..c284211817 --- /dev/null +++ b/.changeset/team-member-org-screen.md @@ -0,0 +1,37 @@ +--- +"@objectstack/plugin-approvals": patch +--- + +Screen expanded `team` approver members to the request's organization (#10547). + +#10230 made a `team` approver prove the TEAM's tenancy, and deferred the +members on purpose. `sys_team_member` carries `team_id` and `user_id` and no +organization column, so a team that passed that screen still routed every user +id it listed — including a user whose only `sys_member` row is in another +organization. Measured on a fixture, not read off the schema: an `org_a` +request against an `org_a` team returned `["u_outsider","u_insider"]` with zero +`sys_member` reads. + +The expansion now screens the members with the same provably-outside posture +the neighbouring screens pin, in ONE `$in` read for the whole slate: + +- membership rows exist for the user and none is the request's organization + (present and NEGATIVE) — dropped, with a warning naming the users, the team + and both organizations; +- no membership rows, an unreadable `sys_member`, a possibly-truncated read, or + a request carrying no organization (ABSENT) — routing is left exactly as it + was, and the no-organization case performs no read at all. + +Holding membership elsewhere is not disqualifying; holding none here is. + +⚠️ Behaviour change, confined to one non-default policy: a node whose only +approver is a team staffed entirely by users provably outside the organization +now resolves to no one. Under the default `onEmptyApprovers: 'admin_rescue'` it +still opens, routed to the dead `team:` literal as any unresolved slate is; +under `onEmptyApprovers: 'fail'` it now throws `NO_APPROVERS` where it +previously opened. + +Residual condition on the security value: the screen can only act on tenancy +facts that exist. A deployment that stamps an organization on its approval +requests but does not materialize `sys_member` rows sees no change — by design, +since #3807 recorded what treating an absent fact as a negative one costs. diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index b7a5aed819..f7c52f8910 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -268,6 +268,18 @@ function actingUserId(context: ExecutionContext | undefined): string | null { */ const OOO_MAX_CHAIN = 8; +/** + * Row cap on the single `sys_member` read that screens an expanded team slate + * (#10547, {@link ApprovalService.dropMembersProvablyOutsideOrg}). + * + * Sized above the 10000-member cap the `sys_team_member` expansion itself + * carries, because the screen reads MEMBERSHIP rows and a person may hold + * several. A result that comes back at the cap is treated as no evidence at + * all — see the truncation note on that method; the cap is therefore a + * fail-open threshold, never a silent trim of the slate. + */ +const MEMBER_SCREEN_READ_LIMIT = 50000; + /** * Approver types resolved by QUERYING a graph rather than by taking `value` * literally (#3807). Each can legitimately come back empty — an unstaffed @@ -1187,15 +1199,35 @@ export class ApprovalService implements IApprovalService { * team routed that organization's people an approval over a record they are * not a tenant of. * - * ⚠️ The screen is on the TEAM, not on its members, and that is the whole - * difference from the screen next door ({@link managerIsProvablyOutsideOrg}, - * #10153). `sys_user` carries no tenancy fact at all, so a manager can only - * be placed by his `sys_member` rows; `sys_team` carries `organization_id` - * outright (`packages/platform-objects/src/identity/sys-team.object.ts`), so - * a team id transitively names exactly one organization and ONE row answers - * the question. Screening the MEMBERS instead would be both a wider read and - * a different assertion — it would rule on #7497 (does approver routing imply - * record read visibility?), which this card does not. + * TWO screens run here, and they assert different things (#10230, #10547): + * + * 1. the TEAM must not provably belong to another organization + * ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries + * `organization_id` outright + * (`packages/platform-objects/src/identity/sys-team.object.ts`), so a + * team id transitively names exactly one organization and ONE row + * answers the question; + * 2. each expanded MEMBER must not provably hold membership only in other + * organizations ({@link dropMembersProvablyOutsideOrg}) — + * `sys_team_member` carries `team_id` and `user_id` and NO tenancy + * column at all, so passing (1) says nothing whatever about the people + * it lists. + * + * #10230 landed (1) alone and deferred (2) on purpose. What closed the + * deferral is that (1) does not imply (2) even a little: a member removed + * from the organization but left on the team, a team re-parented across + * organizations (`/organization/update-team` accepts `organizationId` in its + * partial body), or a `sys_team_member` row written by a seed rather than + * through better-auth all produce a team that passes (1) carrying a user who + * is provably a tenant of somewhere else. Measured on this tree, not read off + * the schema — the probe is quoted in `team-member-org-screen.test.ts`. + * + * (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop + * further out, and it is asserted the same way: `sys_user` carries no tenancy + * fact, so `sys_member` rows are the only evidence that a person is placed + * anywhere. Like that screen, this one grants no reads and applies no read + * screen to any approver type that lacks one today, so it decides nothing + * #7497 (does approver routing imply record read visibility?) asks. */ private async expandTeamUsers(teamId: string, organizationId?: string | null): Promise { if (!teamId) return []; @@ -1209,7 +1241,10 @@ export class ApprovalService implements IApprovalService { context: SYSTEM_CTX, } as any); } catch { rows = []; } - return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean))); + const users = Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean))); + // #10547: the TEAM proved its tenancy above; its members have not proved + // theirs, and `sys_team_member` holds no fact that could. One read. + return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId); } /** @@ -1272,6 +1307,109 @@ export class ApprovalService implements IApprovalService { return true; } + /** + * Drop the expanded team members who are PROVABLY tenants of other + * organizations and not of `organizationId`. (#10547) + * + * Returns the survivors, in the order they were expanded. + * + * Posture — identical to {@link managerIsProvablyOutsideOrg} and + * {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same + * assertion about the same table: + * + * - membership rows exist for this user, none in `organizationId` + * ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him; + * - no membership rows at all for him, the read failed, or the request + * carries no organization + * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was. + * + * The absent limb is load-bearing rather than timid, and #3807 is the recorded + * cost of getting it wrong: a stack that stamps an organization on requests + * but never materializes `sys_member` rows would otherwise lose EVERY team + * approver at once. This package's own `team_ok` expansion fixture and + * #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and + * a request organization and no `sys_member` table at all — so the absent + * limb is exercised by neighbours on every run of this suite. + * + * ONE read for the whole slate, never one per person: the expansion is capped + * at 10000 members and a per-user query would turn a single team approver + * into 10000 round trips. + * + * ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is + * the only evidence that a member IS a tenant here, so a result cut off at + * the limit could be missing the very row that keeps a legitimate approver on + * the slate — screening him out on missing evidence, which inverts the + * posture into fail-CLOSED precisely where it must not. When the read comes + * back at the cap it is treated as no evidence at all. + */ + private async dropMembersProvablyOutsideOrg( + teamId: string, + userIds: string[], + organizationId?: string | null, + ): Promise { + const requestOrg = organizationId ? String(organizationId) : ''; + // No organization on the request ⇒ nothing to screen against, and no read. + // The ordinary single-organization / embedded stack costs nothing here. + if (!requestOrg || !userIds.length) return userIds; + let rows: any[] = []; + try { + // No `as any` on this options bag — #4918's ratchet grandfathers this + // file for its EXISTING erasures only, and a NEW one must carry the + // declared type. `ApprovalEngine.find` already accepts it as written. + rows = await this.engine.find('sys_member', { + where: { user_id: { $in: userIds } }, + fields: ['user_id', 'organization_id'], + limit: MEMBER_SCREEN_READ_LIMIT, + context: SYSTEM_CTX, + }); + } catch { return userIds; } // membership unreadable — see the fail-open note + if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) { + // Possibly truncated ⇒ the evidence is incomplete ⇒ no evidence. + this.logger?.warn?.( + `[approvals] #10547: the membership screen for team '${teamId}' read ` + + `${rows.length} 'sys_member' rows, at or above its ${MEMBER_SCREEN_READ_LIMIT}-row ` + + `cap, so the result may be truncated. Routing is left unchanged rather than risk ` + + `dropping a member whose proof of membership fell outside the read.`, + { teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }, + ); + return userIds; + } + const orgsByUser = new Map(); + for (const r of rows ?? []) { + const uid = String((r as any)?.user_id ?? ''); + const org = String((r as any)?.organization_id ?? ''); + if (!uid || !org) continue; + const seen = orgsByUser.get(uid); + if (seen) seen.push(org); else orgsByUser.set(uid, [org]); + } + const kept: string[] = []; + const dropped: Array<{ userId: string; organizationIds: string[] }> = []; + for (const uid of userIds) { + const orgs = orgsByUser.get(uid); + if (!orgs?.length) { kept.push(uid); continue; } // no tenancy fact recorded + if (orgs.includes(requestOrg)) { kept.push(uid); continue; } // a member here + dropped.push({ userId: uid, organizationIds: orgs }); + } + if (dropped.length) { + this.logger?.warn?.( + `[approvals] #10547: ${dropped.length} member(s) of team '${teamId}' were dropped from ` + + `the approver slate — ${dropped.map(d => `'${d.userId}'`).join(', ')} hold membership ` + + `in other organization(s), none of them the request's organization '${requestOrg}', so ` + + `routing this approval to them would put approval authority over the record outside its ` + + `tenant. The TEAM itself belongs to this organization; its 'sys_team_member' rows carry ` + + `no organization of their own. Remove them from the team, grant them a membership in ` + + `this organization, or route this step with an approver type that names someone in it.`, + { + teamId, + requestOrganizationId: requestOrg, + droppedUserIds: dropped.map(d => d.userId), + droppedMemberOrganizationIds: dropped.map(d => d.organizationIds), + }, + ); + } + return kept; + } + /** * Tenant scope for a `sys_business_unit` read that may legitimately be * env-wide (#3807). diff --git a/packages/plugins/plugin-approvals/src/team-member-org-screen.test.ts b/packages/plugins/plugin-approvals/src/team-member-org-screen.test.ts new file mode 100644 index 0000000000..a0d7c4ee3e --- /dev/null +++ b/packages/plugins/plugin-approvals/src/team-member-org-screen.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +/** + * #10547 — the expanded TEAM MEMBERS are screened to the request's organization. + * + * #10230 made the TEAM prove its tenancy. `sys_team_member` carries `team_id` + * and `user_id` and no organization column at all + * (`packages/platform-objects/src/identity/sys-team-member.object.ts`), so a + * team that passes that screen still says nothing about the people it lists. + * + * The card filed itself as a CODE READING and asked for a measurement first. + * Measured on this tree, same fixture, before the fix — quoted verbatim from + * the probe: + * + * [PROBE P] pending_approvers = ["u_outsider","u_insider"] + * [PROBE P] sys_member reads = 0 + * + * `u_outsider`'s only `sys_member` row is in `org_b`; the request is `org_a` + * and the team is stamped `org_a`. He held approval authority over an `org_a` + * record, and nothing had read `sys_member` at all. The hole is reachable. + * + * Two directions are pinned throughout, because pinning only the first would + * sit green over an implementation that screened out EVERY member — the exact + * failure #10230's ablation leg B demonstrated on the sibling card: + * + * M1 — a member provably outside the organization no longer enters the slate. + * M2 — a member of THIS organization still does. + * M3 — a member with NO `sys_member` row at all still does: the tenancy + * fact is ABSENT, and #3807 is the recorded cost of reading absent as + * negative (every seeded approver resolved to nobody). + * M4 — an unreadable `sys_member` leaves routing alone (fail-open on a + * fault, never an emptied live slate). + * M5 — a request carrying no organization is untouched, and performs no read. + * M6 — THE MIXED TEAM, both directions in one assertion: the outsider is + * dropped and the insider is kept, from one expansion. + * M7 — a member of the request's org AND another org still routes (holding + * membership elsewhere is not disqualifying; holding none here is). + * M8 — ONE read for the whole slate, not one per person. + * M9 — the drop is LOUD: the warning names the users, both organizations + * and the card. + * M10 — a TRUNCATED membership read fails OPEN. This read is the only + * evidence a member is a tenant here, so an incomplete result must not + * be spent as proof of absence. + * E1/E2 — the same two directions through the `expression` / `resolveAs: + * 'team'` path, which is the second call site. + * C-b — THE ACCEPT-TO-REJECT FLIP (Clause-2). Under `onEmptyApprovers: + * 'fail'` a node whose sole approver is a same-org team staffed only + * by outsiders used to OPEN; it now throws `NO_APPROVERS`. That throw + * is PRE-EXISTING code and a bare `Error`, not a minted ADR-0112 + * envelope — there is no `code` / `status` to assert here, and + * inventing one would be a fiction (the reading #10153 and #10230 + * both record for their own flips). + * C-b2 — the same node under the DEFAULT policy (`admin_rescue`) still OPENS, + * which is what confines the flip to one non-default policy. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; +import type { ApprovalRequestRow } from '@objectstack/spec/contracts'; +import { ApprovalService, type ApprovalNodeAutoOutcome } from './approval-service.js'; + +function makeFakeEngine() { + const tables: Record = {}; + const ensure = (n: string) => (tables[n] ??= []); + function matches(row: any, filter: any): boolean { + if (!filter || typeof filter !== 'object') return true; + for (const [k, v] of Object.entries(filter)) { + if (k === '$or') { if (!(v as any[]).some(s => matches(row, s))) return false; continue; } + if (k === '$and') { if (!(v as any[]).every(s => matches(row, s))) return false; continue; } + const rv = row[k]; + if (v != null && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.includes(rv)) return false; continue; + } + if (v != null && typeof v === 'object' && '$ne' in (v as any)) { + if (rv === (v as any).$ne) return false; continue; + } + if (rv !== v) return false; + } + return true; + } + return { + _tables: tables, + _reads: [] as string[], + async find(object: string, options?: any) { + this._reads.push(object); + const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where)); + return rows.slice(0, options?.limit ?? 1000); + }, + async insert(object: string, data: any) { ensure(object).push({ ...data }); return { ...data }; }, + async update(object: string, data: any, options?: any) { + // Pinned to ObjectQL.update's OWN dispatch predicate — a double looser + // than the engine it stands in for turns a green suite into no suite. + const dispatch = assertEngineUpdateDispatch(data, options); + const t = ensure(object); + if (dispatch.kind === 'multi') { + let n = 0; + for (let i = 0; i < t.length; i++) { + if (matches(t[i], options?.where)) { t[i] = { ...t[i], ...data }; n++; } + } + return { updated: n }; + } + const i = t.findIndex(r => r.id === dispatch.id); + if (i >= 0) t[i] = { ...t[i], ...data }; + return t[i]; + }, + async delete(object: string, options?: any) { + const dispatch = assertEngineDeleteDispatch(options); + const t = ensure(object); + if (dispatch.kind === 'multi') { + const survivors = t.filter(r => !matches(r, options?.where)); + const deleted = t.length - survivors.length; + t.splice(0, t.length, ...survivors); + return { deleted }; + } + const i = t.findIndex(r => r.id === dispatch.id); + if (i >= 0) t.splice(i, 1); + return { id: dispatch.id }; + }, + registerHook() {}, unregisterHooksByPackage() { return 0; }, async fire() {}, + }; +} + +/** See the identical note in `team-approver-org-screen.test.ts` (#10230). */ +function opened(result: ApprovalRequestRow | ApprovalNodeAutoOutcome): ApprovalRequestRow { + if ('autoApproved' in result) { + throw new Error('expected an OPENED approval request, got an auto-approval outcome'); + } + return result; +} + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; +const CTX_A = { userId: 'u_sub', organizationId: ORG_A, positions: [], permissions: [] } as any; +const TEAM_A = { type: 'team', value: 'team_a' }; + +function input(approvers: any[], configExtra: Record = {}, recordId = 'opp1') { + return { + object: 'opportunity', recordId, runId: 'run_1', nodeId: 'approve_step', + flowName: 'deal_approval', + config: { approvers, behavior: 'first_response' as const, lockRecord: false, ...configExtra }, + record: { id: recordId, owner_id: 'u_sub', amount: 100 }, + }; +} + +describe('#10547 team MEMBER org screen', () => { + let engine: ReturnType; + let svc: ApprovalService; + let warnings: Array<[any, any]>; + let n = 0; + + beforeEach(() => { + engine = makeFakeEngine(); + warnings = []; + n = 0; + svc = new ApprovalService({ + engine: engine as any, + clock: { now: () => new Date(new Date('2026-01-15T10:00:00Z').getTime() + (n++) * 1000) }, + logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) } as any, + }); + // The TEAM is this organization's — it passes #10230's screen outright. + // Everything below is therefore about its MEMBERS, never about the team. + engine._tables['sys_team'] = [{ id: 'team_a', name: 'A team', organization_id: ORG_A }]; + engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_a', user_id: 'u_outsider' }]; + engine._tables['sys_member'] = [{ id: 'm1', user_id: 'u_outsider', organization_id: ORG_B }]; + }); + + it('M1 — a member provably outside the organization is screened OUT', async () => { + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); + console.log('[PROBE M1] org_a request, org_a team, org_b-only member -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + // Inverts the card's measured [PROBE P], which had u_outsider on the slate. + expect(req.pending_approvers).toEqual(['team:team_a']); + expect((req.pending_approvers ?? []).some((x: string) => !x.includes(':'))).toBe(false); + }); + + it('M2 — a member of THIS organization still routes', async () => { + engine._tables['sys_team_member'] = [{ id: 'tm2', team_id: 'team_a', user_id: 'u_insider' }]; + engine._tables['sys_member'] = [{ id: 'm2', user_id: 'u_insider', organization_id: ORG_A }]; + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); + console.log('[PROBE M2] same-org member -> pending_approvers =', JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_insider']); + }); + + it('M3 — a member with NO `sys_member` row at all still routes (fact absent, #3807)', async () => { + engine._tables['sys_member'] = []; + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); + console.log('[PROBE M3] no membership rows -> pending_approvers =', JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_outsider']); + }); + + it('M4 — an unreadable `sys_member` leaves routing alone (fail-open on a fault)', async () => { + const realFind = engine.find.bind(engine); + engine.find = (async (object: string, options?: any) => { + if (object === 'sys_member') throw new Error('connection reset'); + return realFind(object, options); + }) as any; + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); + console.log('[PROBE M4] sys_member unreadable -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_outsider']); + }); + + it('M5 — a request carrying no organization is untouched, and reads no `sys_member`', async () => { + const ctxNoOrg = { userId: 'u_sub', positions: [], permissions: [] } as any; + engine._reads.length = 0; + const req = opened(await svc.openNodeRequest(input([TEAM_A]), ctxNoOrg)); + console.log('[PROBE M5] no request org -> pending_approvers =', + JSON.stringify(req.pending_approvers), '· sys_member reads =', + engine._reads.filter(r => r === 'sys_member').length); + expect(req.pending_approvers).toEqual(['u_outsider']); + expect(engine._reads.filter(r => r === 'sys_member')).toEqual([]); + }); + + it('M6 — THE MIXED TEAM: the outsider is dropped and the insider is kept, one expansion', async () => { + engine._tables['sys_team_member'].push({ id: 'tm2', team_id: 'team_a', user_id: 'u_insider' }); + engine._tables['sys_member'].push({ id: 'm2', user_id: 'u_insider', organization_id: ORG_A }); + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); + console.log('[PROBE M6] mixed team -> pending_approvers =', JSON.stringify(req.pending_approvers)); + // An implementation that screened out EVERYONE would yield ['team:team_a'] + // and an unscreened one ['u_outsider','u_insider']. Only the correct one + // yields exactly the insider. + expect(req.pending_approvers).toEqual(['u_insider']); + }); + + it('M7 — a member of this org AND another still routes (multi-org membership is fine)', async () => { + engine._tables['sys_member'] = [ + { id: 'm1', user_id: 'u_outsider', organization_id: ORG_B }, + { id: 'm3', user_id: 'u_outsider', organization_id: ORG_A }, + ]; + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); + console.log('[PROBE M7] member of both orgs -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_outsider']); + }); + + it('M8 — ONE `sys_member` read for the whole slate, not one per person', async () => { + for (let i = 0; i < 5; i++) { + engine._tables['sys_team_member'].push({ id: `tmx${i}`, team_id: 'team_a', user_id: `u${i}` }); + engine._tables['sys_member'].push({ id: `mx${i}`, user_id: `u${i}`, organization_id: ORG_A }); + } + engine._reads.length = 0; + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); + const reads = engine._reads.filter(r => r === 'sys_member').length; + console.log('[PROBE M8] 6-member team -> sys_member reads =', reads, + '· pending_approvers =', JSON.stringify(req.pending_approvers)); + expect(reads).toBe(1); + expect(req.pending_approvers).toEqual(['u0', 'u1', 'u2', 'u3', 'u4']); + }); + + it('M9 — the drop is loud: the warning names the users, both organizations and the card', async () => { + await svc.openNodeRequest(input([TEAM_A]), CTX_A); + const hit = warnings.find(([msg]) => String(msg).includes('#10547')); + console.log('[PROBE M9] warning =', hit ? String(hit[0]).slice(0, 100) : 'NONE'); + expect(hit).toBeTruthy(); + expect(String(hit![0])).toContain(ORG_A); + expect(hit![1]).toMatchObject({ + teamId: 'team_a', requestOrganizationId: ORG_A, droppedUserIds: ['u_outsider'], + }); + expect(hit![1].droppedMemberOrganizationIds).toEqual([[ORG_B]]); + }); + + it('M10 — a TRUNCATED membership read fails OPEN, never closed', async () => { + // The screen asks for at most MEMBER_SCREEN_READ_LIMIT rows. A driver that + // returns the cap may have more behind it, so the evidence is incomplete — + // and incomplete evidence must not be spent as proof that a member is not a + // tenant here. + const realFind = engine.find.bind(engine); + engine.find = (async (object: string, options?: any) => { + if (object === 'sys_member') { + const cap = Number(options?.limit ?? 0); + return Array.from({ length: cap }, (_, i) => ( + { id: `flood${i}`, user_id: 'someone_else', organization_id: ORG_B } + )); + } + return realFind(object, options); + }) as any; + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); + console.log('[PROBE M10] truncated membership read -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_outsider']); + expect(warnings.some(([m]) => String(m).includes('truncated'))).toBe(true); + }); + + it('E1 — the `expression` / resolveAs:team path screens the outside member too', async () => { + const req = opened(await svc.openNodeRequest(input([ + { type: 'expression', value: '"team_a"', resolveAs: 'team' }, + ]), CTX_A)); + console.log('[PROBE E1] expression resolveAs:team, org_b-only member -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['team:team_a']); + }); + + it('E2 — the same path still routes a SAME-org member', async () => { + engine._tables['sys_team_member'] = [{ id: 'tm2', team_id: 'team_a', user_id: 'u_insider' }]; + engine._tables['sys_member'] = [{ id: 'm2', user_id: 'u_insider', organization_id: ORG_A }]; + const req = opened(await svc.openNodeRequest(input([ + { type: 'expression', value: '"team_a"', resolveAs: 'team' }, + ]), CTX_A)); + console.log('[PROBE E2] expression resolveAs:team, same-org member -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_insider']); + }); + + it('C-b — THE FLIP: sole same-org team staffed only by outsiders + onEmptyApprovers:fail THROWS', async () => { + let err: any = null; + try { + await svc.openNodeRequest(input([TEAM_A], { onEmptyApprovers: 'fail' }), CTX_A); + } catch (e) { err = e; } + console.log('[PROBE C-b] threw =', err ? String(err.message).slice(0, 90) : 'NOTHING'); + expect(err).toBeTruthy(); + expect(String(err.message)).toMatch(/^NO_APPROVERS:/); + }); + + it('C-b2 — the SAME node under the DEFAULT policy still opens (the flip is confined)', async () => { + const req = opened(await svc.openNodeRequest(input([TEAM_A]), CTX_A)); // absent => admin_rescue + console.log('[PROBE C-b2] status =', req.status, 'approvers =', JSON.stringify(req.pending_approvers)); + expect(req.status).toBe('pending'); + expect(req.pending_approvers).toEqual(['team:team_a']); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index c79605ac78..c6c6c836ac 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1181,6 +1181,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-approvals/src/team-member-org-screen.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-approvals/src/team-member-org-screen.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts", "verb": "delete",