Skip to content

Commit 1a3854d

Browse files
committed
test(plugin-approvals): the cross-org read-filter refusal pins live in the shared approval-service harness
Moved as one top-level describe into approval-service.test.ts, on the shared fake engine, with the pass-through spy on the read filter declared there; the separate file is gone. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
1 parent c4c64fd commit 1a3854d

2 files changed

Lines changed: 75 additions & 132 deletions

File tree

‎packages/plugins/plugin-approvals/src/approval-service.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,17 @@ import {
1515
ApprovalService, ApproverDirectoryUnreadableError, REMIND_COOLDOWN_MS, ESCALATION_ENABLED_FLIP_CUTOFF_MS, SLA_ACTOR_ID,
1616
} from './approval-service.js';
1717
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
18+
import { filterApproversWhoCanRead } from './approver-org-scope.js';
1819
import { celEngine } from '@objectstack/formula';
1920
import { SysApprovalRequest } from './sys-approval-request.object.js';
2021

22+
// [#29604] A pass-through spy on the ADR-0105 D9 read filter, so one describe
23+
// below can make it reject. Every other case runs the real filter through it.
24+
vi.mock('./approver-org-scope.js', async (importOriginal) => {
25+
const actual = await importOriginal<typeof import('./approver-org-scope.js')>();
26+
return { ...actual, filterApproversWhoCanRead: vi.fn(actual.filterApproversWhoCanRead) };
27+
});
28+
2129
interface FakeRow { [k: string]: any }
2230

2331
/**
@@ -4341,6 +4349,73 @@ describe('ApprovalService — a team, position or queue approver whose directory
43414349
});
43424350
});
43434351

4352+
// [ADR-0142 D6 as amended; ruling record 6599731698 on #29604, letter B] The
4353+
// ADR-0105 D9 cross-organization read filter is a read behind a `position` or
4354+
// `team` approver as much as the directory expansion is. When it fails, the
4355+
// node refuses, naming THAT read, on the static resolver (its `bounded`
4356+
// filter) and on an `expression` approver's `resolveAs` values alike.
4357+
// `filterApproversWhoCanRead` answers its own failed `sys_member` read by
4358+
// keeping the slate, so the pass-through spy above is how it is made to fail.
4359+
// The scenario is `approver-cross-org.integration.test.ts`'s: a Plant A
4360+
// request routed to the group CFO.
4361+
describe('ApprovalService — a failed cross-organization read filter refuses the node, named as that read (ADR-0142 D6)', () => {
4362+
const PLANT_CTX = { userId: 'u_submitter', organizationId: 'o_plant', positions: [], permissions: [] } as any;
4363+
const FILTER_READ = /^the cross-organization read filter \(who among the people of organization 'o_group' can read this request of organization 'o_plant'\) could not be read: membership read filter down\.$/;
4364+
let engine: ReturnType<typeof makeFakeEngine>;
4365+
let svc: ApprovalService;
4366+
let n = 0;
4367+
4368+
beforeEach(() => {
4369+
vi.mocked(filterApproversWhoCanRead).mockClear();
4370+
engine = makeFakeEngine();
4371+
engine._tables['sys_user'].push({ id: 'u_cfo' });
4372+
engine._tables['sys_organization'] = [
4373+
{ id: 'o_group', slug: 'acme-group', parent_organization_id: null },
4374+
{ id: 'o_plant', slug: 'acme-plant-a', parent_organization_id: 'o_group' },
4375+
];
4376+
engine._tables['sys_user_position'] = [{ id: 'up1', user_id: 'u_cfo', position: 'cfo', organization_id: 'o_group' }];
4377+
engine._tables['sys_member'] = [{ id: 'm1', user_id: 'u_cfo', organization_id: 'o_plant', role: 'member' }];
4378+
n = 0;
4379+
svc = new ApprovalService({
4380+
engine: engine as any,
4381+
clock: { now: () => new Date(1767000000000 + (n++) * 1000) },
4382+
tenancyPosture: () => 'group',
4383+
});
4384+
});
4385+
4386+
const node = (approver: Record<string, unknown>) => ({
4387+
object: 'purchase_order',
4388+
recordId: 'po1',
4389+
runId: 'run_1',
4390+
nodeId: 'group_signoff',
4391+
flowName: 'po_approval',
4392+
config: { approvers: [approver as any], behavior: 'unanimous' as const, lockRecord: false, onEmptyApprovers: 'admin_rescue' as const },
4393+
record: { id: 'po1', signoff_position: 'cfo' },
4394+
});
4395+
const refusalOf = (p: Promise<unknown>) => p.then(() => null, (e: unknown) => e);
4396+
4397+
for (const [label, approver] of [
4398+
['a static position approver', { type: 'position', value: 'cfo', organization: '$root' }],
4399+
['an expression approver resolving as position', { type: 'expression', value: 'trigger.signoff_position', resolveAs: 'position', organization: '$root' }],
4400+
] as const) {
4401+
it(`control — ${label} reaches the group CFO through the filter`, async () => {
4402+
const req = await svc.openNodeRequest(node(approver), PLANT_CTX) as any;
4403+
expect(req.pending_approvers).toEqual(['u_cfo']);
4404+
expect(filterApproversWhoCanRead).toHaveBeenCalledTimes(1);
4405+
});
4406+
4407+
it(`${label}: the filter's failure refuses the node and names the filter's read`, async () => {
4408+
vi.mocked(filterApproversWhoCanRead).mockRejectedValueOnce(new Error('membership read filter down'));
4409+
const refusal = await refusalOf(svc.openNodeRequest(node(approver), PLANT_CTX));
4410+
4411+
expect(refusal).toBeInstanceOf(ApproverDirectoryUnreadableError);
4412+
expect(refusal).toMatchObject({ approverType: 'position', approverValue: 'cfo', retryable: true });
4413+
expect((refusal as ApproverDirectoryUnreadableError).failedRead).toMatch(FILTER_READ);
4414+
expect(engine._tables['sys_approval_request'] ?? []).toEqual([]);
4415+
});
4416+
}
4417+
});
4418+
43444419
// #3807 follow-up: `queue` was not the only way to end up with a slot nobody
43454420
// can act on — every GRAPH approver type falls back to the same literal when
43464421
// its lookup finds no one, and that fallback used to happen in total silence.

‎packages/plugins/plugin-approvals/src/approver-directory-refusal-cross-org.test.ts‎

Lines changed: 0 additions & 132 deletions
This file was deleted.

0 commit comments

Comments
 (0)