diff --git a/.changeset/team-approver-org-screen.md b/.changeset/team-approver-org-screen.md new file mode 100644 index 0000000000..0f61ff3455 --- /dev/null +++ b/.changeset/team-approver-org-screen.md @@ -0,0 +1,30 @@ +--- +"@objectstack/plugin-approvals": patch +--- + +**Who loses access:** members of a team belonging to a *different* organization +than the record being approved. Concretely — a request raised in `org_a` routed +to a `team` approver whose `sys_team.organization_id` is `org_b` used to place +every `sys_team_member` of that team into `pending_approvers`, giving them the +approve/reject buttons on a record they are not a tenant of. They no longer +enter the slate, and the step falls back to the dead `team:` literal with +the existing `#3807` "expanded to nobody" warning — the same shape a cross-org +`position` approver has always produced (#10230). + +`team` was the last approver expansion that resolved people without asking +which organization was asking; `department`, `position`, `org_membership_level` +and (since #10153) `manager` all do. The screen reads the team's own +`organization_id`, so it costs one row and a team that fails it never fans out. + +**Who does not lose access**, deliberately: a team stamped with the request's +own organization; a team stamped with **no** organization (`organization_id: +null` on a platform object means "owned by no organization" — what a seed +writes, since a seed cannot know the id the runtime mints at boot); a team id +with no `sys_team` row at all; and any request that carries no organization — +all four leave routing exactly as it was, because the tenancy fact is absent +rather than negative. + +⚠️ One externally observable accept→reject change beyond the routing itself: +under the non-default `onEmptyApprovers: 'fail'` policy, a node whose *sole* +approver was a cross-org team used to open a request and now throws +`NO_APPROVERS`. Under the default (`admin_rescue`) the node still opens. diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 8e66838682..b7a5aed819 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -971,7 +971,12 @@ export class ApprovalService implements IApprovalService { try { if (type === 'team') { - const users = await this.expandTeamUsers(String(a.value)); + // #10230: the request's OWN organization, not `directoryOrg`. They are + // provably equal on this branch (`team` is not org-scoped, so an + // `organization` declaration is refused above), and naming the request + // org says what the screen asserts: tenancy of the record being + // approved, never an ADR-0105 D9 retarget this type does not have. + const users = await this.expandTeamUsers(String(a.value), organizationId); if (users.length) return users; } else if (type === 'department' || type === 'business_unit' || type === 'bu') { const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg)); @@ -1139,7 +1144,14 @@ export class ApprovalService implements IApprovalService { try { if (resolveAs === 'department') users = await this.expandBusinessUnitUsers(key, directoryOrg); else if (resolveAs === 'position') users = await this.expandPositionUsers(key, directoryOrg); - else if (resolveAs === 'team') users = await this.expandTeamUsers(key); + // #10230: `directoryOrg` and NOT the request org, the opposite of the + // static `team` branch — and deliberately so. `expression` IS org-scoped + // (APPROVER_ORG_SCOPED), so a declaration here resolves to a legitimately + // retargeted sibling organization and the team must belong to the + // directory actually being consulted. `filterApproversWhoCanRead` below + // then applies the D2 read screen to what comes back, exactly as it + // already does for the other `resolveAs` kinds. + else if (resolveAs === 'team') users = await this.expandTeamUsers(key, directoryOrg); else { throw new Error( `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' — ` @@ -1164,9 +1176,30 @@ export class ApprovalService implements IApprovalService { return { slots, raw }; } - /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */ - private async expandTeamUsers(teamId: string): Promise { + /** + * Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). + * + * Takes an organization for the reason every sibling expansion does + * ({@link expandBusinessUnitUsers}, {@link expandPositionUsers}, + * {@link expandMembershipTierUsers}): an approver expansion answers "who, in + * THIS organization". Before #10230 this one did not ask, and it was the last + * expansion that did not — a `team` approver naming ANOTHER organization's + * 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. + */ + private async expandTeamUsers(teamId: string, organizationId?: string | null): Promise { if (!teamId) return []; + if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return []; let rows: any[] = []; try { rows = await this.engine.find('sys_team_member', { @@ -1179,6 +1212,66 @@ export class ApprovalService implements IApprovalService { return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean))); } + /** + * Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230) + * + * "Provably" carries the same posture the sibling screen states at length in + * {@link managerIsProvablyOutsideOrg}, for the same reasons: + * + * - the team row carries an `organization_id` and it is not the request's + * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out; + * - the row carries no `organization_id`, does not exist, or the read failed + * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was. + * + * The `organization_id = null` limb is not timidity — it is the reading + * {@link businessUnitOrgScope} settled on one screen below, for the identical + * shape: null on a platform object means "owned by no organization", which is + * what a seed writes because a seed cannot know the organization id the + * runtime mints at boot. Treating null as "not mine" would delete every + * seeded team approver at once — a larger behaviour change than the hole + * being closed. Measured, and not hypothetically: this package's own + * `team_ok` expansion fixture is exactly such a stack (it has + * `sys_team_member` rows, a request carrying an organization, and no + * `sys_team` row at all). + * + * Screening the TEAM before reading its members is also what keeps the cost + * at one row: a team that fails the screen never fans out. + */ + private async teamIsProvablyOutsideOrg( + teamId: 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) return false; + 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_team', { + where: { id: teamId }, + fields: ['id', 'organization_id'], + limit: 1, + context: SYSTEM_CTX, + }); + } catch { return false; } // team unreadable — see the fail-open note above + const row: any = Array.isArray(rows) ? rows[0] : null; + const teamOrg = row?.organization_id ? String(row.organization_id) : ''; + if (!teamOrg) return false; // no tenancy fact on this team + if (teamOrg === requestOrg) return false; // it is this org's team — route as before + this.logger?.warn?.( + `[approvals] #10230: team '${teamId}' was dropped from the approver slate — ` + + `'sys_team.organization_id' is '${teamOrg}', not the request's organization ` + + `'${requestOrg}', so routing this approval to its members would put approval ` + + `authority over the record outside its tenant. Point the approver at a team in ` + + `this organization, or route this step with an approver type that names someone in it.`, + { teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }, + ); + return true; + } + /** * Tenant scope for a `sys_business_unit` read that may legitimately be * env-wide (#3807). diff --git a/packages/plugins/plugin-approvals/src/manager-approver-org-screen.test.ts b/packages/plugins/plugin-approvals/src/manager-approver-org-screen.test.ts index af93008915..9ba0119ef1 100644 --- a/packages/plugins/plugin-approvals/src/manager-approver-org-screen.test.ts +++ b/packages/plugins/plugin-approvals/src/manager-approver-org-screen.test.ts @@ -22,8 +22,9 @@ * no read to perform it). * B/B2 — the sibling `position` expansion IS screened, and its screen is not * reject-everything. - * W — `team` is still NOT screened. #10230 owns that; this card did not - * touch it, and the pin says so out loud. + * W — `team` was still NOT screened when this file was written; #10230 + * closed that and INVERTED this pin. It stays here as the cross-file + * statement that no unscreened expansion is left. * C-a — THE ACCEPT-TO-REJECT FLIP. Under `onEmptyApprovers: 'fail'` a node * whose sole approver is a cross-org `manager` used to OPEN; it now * throws `NO_APPROVERS`. That throw is PRE-EXISTING code and a bare @@ -237,11 +238,15 @@ describe('#10153 manager approver org screen', () => { expect(err).toBeTruthy(); }); - it('W — `team` is STILL not org-screened (#10230 owns it; this card did not touch it)', async () => { + it('W — `team` IS org-screened now too (#10230 landed; the gap this pin held is closed)', async () => { engine._tables['sys_team'] = [{ id: 'team_b', name: 'B team', organization_id: 'org_b' }]; engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_b', user_id: 'u_team_b' }]; const req = opened(await svc.openNodeRequest(input([{ type: 'team', value: 'team_b' }]), CTX_A)); console.log('[PROBE W] org_a request, org_b team -> pending_approvers =', JSON.stringify(req.pending_approvers)); - expect(req.pending_approvers).toEqual(['u_team_b']); + // Inverted by #10230, which this pin was written to hand off to. The two + // directions and the `null` / absent-row limbs live in that card's own file + // (`team-approver-org-screen.test.ts`); what stays HERE is the cross-file + // fact this file exists to keep true — the last unscreened expansion is gone. + expect(req.pending_approvers).toEqual(['team:team_b']); }); }); diff --git a/packages/plugins/plugin-approvals/src/team-approver-org-screen.test.ts b/packages/plugins/plugin-approvals/src/team-approver-org-screen.test.ts new file mode 100644 index 0000000000..7af0802f68 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/team-approver-org-screen.test.ts @@ -0,0 +1,282 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +/** + * #10230 — the `team` approver expansion is screened to the request's organization. + * + * `team` was the LAST approver expansion that resolved people without asking + * which organization was asking. Measured on this tree before the fix, and + * quoted verbatim from the card: + * + * [PROBE W] org_a request, org_b team -> pending_approvers = ["u_team_b"] + * + * Two directions are pinned throughout, because only pinning the first would + * sit green over an implementation that filtered out EVERY team: + * + * T1 — a team stamped with ANOTHER organization no longer enters the slate. + * T2 — a team stamped with THIS organization still does. + * T3 — a team stamped with NO organization (`organization_id: null`) still + * does: on a platform object that means "owned by no organization", + * the shape a seed writes, not "owned by someone else". + * T4 — a `team_id` with no `sys_team` row at all still does — the tenancy + * fact is absent, so routing is left exactly as it was. This package's + * own `team_ok` expansion fixture is such a stack. + * T5 — an unreadable `sys_team` leaves routing alone (fail-open on an + * infrastructure fault, never an emptied live slate). + * T6 — a request carrying no organization is untouched, and performs no read. + * T7 — the drop is LOUD: the warning names the team, both organizations + * and the card. + * E1/E2 — the same two directions through the `expression` / `resolveAs: + * 'team'` path, which is the second call site. + * X1 — the failure SHAPE matches the screened siblings: the slot falls back + * to the dead `team:` literal, exactly as a cross-org `position` + * resolves to `position:cfo` (asserted here, in the same run, on the + * same tree). + * C-a — THE ACCEPT-TO-REJECT FLIP (Clause-2). Under `onEmptyApprovers: + * 'fail'` a node whose sole approver is a cross-org `team` 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 same reading #10153's file records for its own flip). + * C-a2 — 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() {}, + }; +} + +/** + * `openNodeRequest` returns `ApprovalRequestRow | ApprovalNodeAutoOutcome` — the + * second arm is the `onEmptyApprovers: 'auto_approve'` exit, which opens no + * request at all. Narrow rather than read through the union: every probe below + * asserts something about an OPENED request, so an auto-approval reaching one + * of them is a wrong answer that must say so, not a property read off the wrong + * arm. (Left unnarrowed, a file like this one bills the package's TEST_DEBT + * ledger in raw TS2339 — invisible to the package `typecheck`, whose tsconfig + * excludes `**\/*.test.ts`.) + */ +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_B = { type: 'team', value: 'team_b' }; + +// `recordId` is a parameter because X1 opens TWO requests in one test and +// `openNodeRequest` refuses a second pending approval on the same record +// (`DUPLICATE_REQUEST`) — a real guard, not something to route around. +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('#10230 team approver 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, + }); + // `team_b` belongs to org_b; the request below is raised in org_a. + engine._tables['sys_team'] = [{ id: 'team_b', name: 'B team', organization_id: ORG_B }]; + engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_b', user_id: 'u_team_b' }]; + // Sibling directory, for the failure-shape contrast in X1: the only `cfo` + // holder is in org_b too. + engine._tables['sys_user_position'] = [ + { id: 'p1', user_id: 'u_pos_b', position: 'cfo', organization_id: ORG_B }, + ]; + }); + + it('T1 — a team belonging to ANOTHER organization is screened OUT', async () => { + const req = opened(await svc.openNodeRequest(input([TEAM_B]), CTX_A)); + console.log('[PROBE T1] org_a request, org_b team -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + // Inverts the card's [PROBE W], which measured ["u_team_b"] here. + expect(req.pending_approvers).toEqual(['team:team_b']); + expect((req.pending_approvers ?? []).some((x: string) => !x.includes(':'))).toBe(false); + }); + + it('T2 — a team belonging to THIS organization still resolves', async () => { + engine._tables['sys_team'].push({ id: 'team_a', name: 'A team', organization_id: ORG_A }); + engine._tables['sys_team_member'].push({ id: 'tm2', team_id: 'team_a', user_id: 'u_team_a' }); + const req = opened(await svc.openNodeRequest(input([{ type: 'team', value: 'team_a' }]), CTX_A)); + console.log('[PROBE T2] same-org team -> pending_approvers =', JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_team_a']); + }); + + it('T3 — a team stamped with NO organization (seeded / env-wide) still resolves', async () => { + engine._tables['sys_team'] = [{ id: 'team_b', name: 'seeded', organization_id: null }]; + const req = opened(await svc.openNodeRequest(input([TEAM_B]), CTX_A)); + console.log('[PROBE T3] null-org team -> pending_approvers =', JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_team_b']); + }); + + it('T4 — a team with NO `sys_team` row at all still resolves (no tenancy fact)', async () => { + engine._tables['sys_team'] = []; + const req = opened(await svc.openNodeRequest(input([TEAM_B]), CTX_A)); + console.log('[PROBE T4] no team row -> pending_approvers =', JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_team_b']); + }); + + it('T5 — an unreadable `sys_team` 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_team') throw new Error('connection reset'); + return realFind(object, options); + }) as any; + const req = opened(await svc.openNodeRequest(input([TEAM_B]), CTX_A)); + console.log('[PROBE T5] sys_team unreadable -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_team_b']); + }); + + it('T6 — a request carrying no organization is untouched, and reads no `sys_team`', async () => { + const ctxNoOrg = { userId: 'u_sub', positions: [], permissions: [] } as any; + engine._reads.length = 0; + const req = opened(await svc.openNodeRequest(input([TEAM_B]), ctxNoOrg)); + console.log('[PROBE T6] no request org -> pending_approvers =', + JSON.stringify(req.pending_approvers), '· sys_team reads =', + engine._reads.filter(r => r === 'sys_team').length); + expect(req.pending_approvers).toEqual(['u_team_b']); + expect(engine._reads.filter(r => r === 'sys_team')).toEqual([]); + }); + + it('T7 — the drop is loud: the warning names the team, both organizations and the card', async () => { + await svc.openNodeRequest(input([TEAM_B]), CTX_A); + const hit = warnings.find(([msg]) => String(msg).includes('#10230')); + console.log('[PROBE T7] warning =', hit ? String(hit[0]).slice(0, 90) : 'NONE'); + expect(hit).toBeTruthy(); + expect(hit![1]).toMatchObject({ + teamId: 'team_b', teamOrganizationId: ORG_B, requestOrganizationId: ORG_A, + }); + }); + + it('E1 — the `expression` / resolveAs:team path screens the cross-org team too', async () => { + const req = opened(await svc.openNodeRequest(input([ + { type: 'expression', value: '"team_b"', resolveAs: 'team' }, + ]), CTX_A)); + console.log('[PROBE E1] expression resolveAs:team, org_b team -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['team:team_b']); + }); + + it('E2 — the same path still resolves a SAME-org team', async () => { + engine._tables['sys_team'].push({ id: 'team_a', name: 'A team', organization_id: ORG_A }); + engine._tables['sys_team_member'].push({ id: 'tm2', team_id: 'team_a', user_id: 'u_team_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 team -> pending_approvers =', + JSON.stringify(req.pending_approvers)); + expect(req.pending_approvers).toEqual(['u_team_a']); + }); + + it('X1 — the failure SHAPE matches the screened sibling `position`, same tree, same run', async () => { + const team = opened(await svc.openNodeRequest(input([TEAM_B]), CTX_A)); + const position = opened(await svc.openNodeRequest( + input([{ type: 'position', value: 'cfo' }], {}, 'opp2'), CTX_A, + )); + console.log('[PROBE X1] team =', JSON.stringify(team.pending_approvers), + '· position =', JSON.stringify(position.pending_approvers)); + // Both fall back to the dead `type:value` literal. The card asked whether + // `team` should differ here; measured, there is no reason to — the literal + // fallback is the file's single answer to "the graph resolved nobody", it + // keeps 15.x stored slots working, and #3807's warning makes it visible. + expect(team.pending_approvers).toEqual(['team:team_b']); + expect(position.pending_approvers).toEqual(['position:cfo']); + }); + + it('C-a — THE FLIP: sole cross-org `team` + onEmptyApprovers:fail now THROWS', async () => { + let err: any = null; + try { + await svc.openNodeRequest(input([TEAM_B], { onEmptyApprovers: 'fail' }), CTX_A); + } catch (e) { err = e; } + console.log('[PROBE C-a] threw =', err ? String(err.message).slice(0, 90) : 'NOTHING'); + expect(err).toBeTruthy(); + expect(String(err.message)).toMatch(/^NO_APPROVERS:/); + }); + + it('C-a2 — the SAME node under the DEFAULT policy still opens (the flip is confined)', async () => { + const req = opened(await svc.openNodeRequest(input([TEAM_B]), CTX_A)); // absent => admin_rescue + console.log('[PROBE C-a2] status =', req.status, 'approvers =', JSON.stringify(req.pending_approvers)); + expect(req.status).toBe('pending'); + expect(req.pending_approvers).toEqual(['team:team_b']); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7dfad72e23..b086e5dbd8 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1071,6 +1071,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-approvals/src/team-approver-org-screen.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-approvals/src/team-approver-org-screen.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts", "verb": "delete",