From 2cdb9a975fdda990df60992b9576a942d0848369 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:27:25 +0000 Subject: [PATCH 1/4] fix(plugin-approvals): the delegation Delegator field offers only the acting user bindDelegationWriteGuard admits one delegator on a non-system write, the acting user, and refuses every other with 403. The field was a free sys_user lookup, so the create form offered every user. It now declares defaultValue 'current_user' and a lookupFilters entry on id equal to {current_user_id}, so the form opens on the acting user and the picker offers nobody else. The guard is unchanged. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- ...proval-delegation-delegator-picker.test.ts | 170 ++++++++++++++++++ .../src/sys-approval-delegation.object.ts | 18 ++ 2 files changed, 188 insertions(+) create mode 100644 packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts new file mode 100644 index 00000000000..b3d41b28ddb --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Delegator field of `sys_approval_delegation` offers exactly what the + * delegation write guard admits. + * + * `bindDelegationWriteGuard` (lifecycle-hooks.ts) admits one delegator on a + * non-system write — the acting user — and refuses every other with 403: a + * delegation names its own author as delegator, and acting for an absent + * approver is the approval-level override (the guard's docstring holds the + * ruling). The field used to be a free `sys_user` lookup, so the console's + * create dialog demanded a choice among every user and the server refused all + * but one of them. It now declares that one value, in the object vocabulary + * the create form honours: + * + * - `defaultValue: 'current_user'` — the create form seeds the acting user + * (the token the console resolves client-side on a `sys_user` lookup), and + * an insert that omits the field stores the acting user; + * - `lookupFilters` on `id` = `{current_user_id}` — the picker's candidate + * query, whose placeholder the engine resolves against the caller on every + * read. + * + * Through a real `ObjectQL` over `SqlDriver` (better-sqlite3, in-memory, real + * DDL) with the guard bound, this file asserts: + * + * 1. the declaration, read off the object; + * 2. the picker's candidate query returns the caller and nobody else, for + * each caller; + * 3. every candidate it offers is admitted by the guard, and a create that + * leaves the field empty stores the caller; + * 4. the guard is not widened: a create or an update naming anyone else is + * still refused, `FORBIDDEN` / 403, and nothing is stored; + * 5. `required` stays: a write with no acting user still has no delegator. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { isCurrentUserDefaultToken } from '@objectstack/spec/data'; +import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { bindDelegationWriteGuard } from './lifecycle-hooks.js'; + +const OBJECT = 'sys_approval_delegation'; +const SYSTEM = { context: { isSystem: true } } as any; +const asUser = (userId: string) => ({ context: { userId, positions: [], permissions: [] } }) as any; + +/** The install's people: an admin covering for Priya, and two colleagues. */ +const USERS = [ + { id: 'u_admin', name: 'Dev Admin' }, + { id: 'u_priya', name: 'Priya Raman' }, + { id: 'u_tomas', name: 'Tomas Okafor' }, +]; + +/** Just enough of `sys_user` for the picker's candidate query. */ +const sysUser = { + name: 'sys_user', + label: 'User', + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + }, +}; + +type LookupFilter = { field: string; operator: string; value: unknown }; + +const delegatorField = (): Record => + (SysApprovalDelegation.fields as Record).delegator_id; + +/** + * The picker's candidate `where`, from the declared `lookupFilters`. The + * console lowers each `eq` entry to `{ [field]: value }` and sends it as the + * `$filter` of its `sys_user` query (objectui `lookupFiltersToRecord`); every + * entry here is `eq`, which the declaration test holds. + */ +function pickerWhere(): Record { + const filters = (delegatorField().lookupFilters ?? []) as LookupFilter[]; + const where: Record = {}; + for (const f of filters) where[f.field] = f.value; + return where; +} + +describe('sys_approval_delegation — the Delegator field offers only what the write guard admits', () => { + let engine: ObjectQL; + + beforeAll(async () => { + engine = new ObjectQL(); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysUser as any, 'com.objectstack.test.delegator-picker'); + engine.registry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.delegator-picker'); + await engine.syncSchemas(); + bindDelegationWriteGuard(engine as any); + for (const u of USERS) await engine.insert('sys_user', u, SYSTEM); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('declares the acting user as the default and as the picker\'s only candidate, and stays required', () => { + const field = delegatorField(); + expect(field.type).toBe('lookup'); + expect(field.reference).toBe('sys_user'); + expect(field.required).toBe(true); + // The token the console's create form resolves to the acting user on a + // `sys_user` lookup, and the engine stamps at insert. + expect(isCurrentUserDefaultToken(field.defaultValue)).toBe(true); + expect(field.lookupFilters).toEqual([{ field: 'id', operator: 'eq', value: '{current_user_id}' }]); + }); + + it('the registered body keeps both declarations', () => { + const registered = (engine.registry.getObject(OBJECT) as any).fields.delegator_id; + expect(isCurrentUserDefaultToken(registered.defaultValue)).toBe(true); + expect(registered.lookupFilters).toEqual(delegatorField().lookupFilters); + }); + + it.each(USERS.map((u) => [u.id]))('the picker offers %s themselves and nobody else', async (caller) => { + const candidates = await engine.find('sys_user', { where: pickerWhere() } as any, asUser(caller)); + expect(candidates.map((r: any) => r.id)).toEqual([caller]); + }); + + it('every candidate the picker offers is admitted by the guard', async () => { + const candidates = await engine.find('sys_user', { where: pickerWhere() } as any, asUser('u_admin')); + expect(candidates.length).toBeGreaterThan(0); + for (const candidate of candidates) { + const created = await engine.insert( + OBJECT, { delegator_id: (candidate as any).id, delegate_id: 'u_tomas' }, asUser('u_admin'), + ); + expect(created.delegator_id).toBe('u_admin'); + } + }); + + it.each([ + ['omitted', { delegate_id: 'u_tomas' }], + ['null', { delegator_id: null, delegate_id: 'u_tomas' }], + ])('a create that leaves the delegator %s stores the acting user', async (_label, data) => { + const created = await engine.insert(OBJECT, { ...data }, asUser('u_admin')); + const row = await engine.findOne(OBJECT, { where: { id: created.id } } as any, SYSTEM); + expect(row?.delegator_id).toBe('u_admin'); + }); + + it('the guard is not widened: a create naming anyone else is refused FORBIDDEN / 403 and stores nothing', async () => { + const before = await engine.count(OBJECT, { where: { delegator_id: 'u_priya' } } as any, SYSTEM); + await expect( + engine.insert(OBJECT, { delegator_id: 'u_priya', delegate_id: 'u_tomas' }, asUser('u_admin')), + ).rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 }); + expect(await engine.count(OBJECT, { where: { delegator_id: 'u_priya' } } as any, SYSTEM)).toBe(before); + }); + + it('the guard is not widened: an update relabelling the delegator is refused FORBIDDEN / 403', async () => { + const own = await engine.insert(OBJECT, { delegate_id: 'u_tomas' }, asUser('u_admin')); + await expect( + engine.update(OBJECT, { id: own.id, delegator_id: 'u_priya' }, asUser('u_admin')), + ).rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 }); + const row = await engine.findOne(OBJECT, { where: { id: own.id } } as any, SYSTEM); + expect(row?.delegator_id).toBe('u_admin'); + }); + + it('`required` stays: a system write with no acting user and no delegator is refused', async () => { + await expect(engine.insert(OBJECT, { delegate_id: 'u_tomas' }, SYSTEM)).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: expect.arrayContaining([expect.objectContaining({ field: 'delegator_id', code: 'required' })]), + }); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index 11f178f3e70..9cb66fd9eff 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -86,9 +86,27 @@ export const SysApprovalDelegation = ObjectSchema.create({ group: 'Delegation', }), + // A delegation names its own author as delegator: on every non-system + // write `bindDelegationWriteGuard` (lifecycle-hooks.ts) admits exactly one + // value here, the acting user, and refuses any other with 403 (its + // docstring holds why there is no admin exemption). So the field declares + // that one value in the vocabulary the create form honours, instead of a + // free `sys_user` lookup that offered every user and saw all but one + // refused: + // - `defaultValue: 'current_user'` — the create form opens on the acting + // user, and an insert that omits the field stores the acting user + // (`applyFieldDefaults`, before the guard runs; the guard stamps the + // same value). `required` stays: a write with no acting user still has + // no delegator, and is refused. + // - `lookupFilters` — the picker's candidate query, which the engine + // resolves `{current_user_id}` in for every read, so it offers the + // acting user and nobody else. + // Pinned in `sys-approval-delegation-delegator-picker.test.ts`. delegator_id: Field.lookup('sys_user', { label: 'Delegator', required: true, + defaultValue: 'current_user', + lookupFilters: [{ field: 'id', operator: 'eq', value: '{current_user_id}' }], group: 'Delegation', description: 'The user going out of office; their individually-routed approver slots are rerouted while active.', }), From 49c6d958febe05ec449da6dbb2189c0717c192bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:40:19 +0000 Subject: [PATCH 2/4] fix(plugin-approvals): narrow the delegation fix to the picker filter Measured: declaring defaultValue 'current_user' changes one engine cell, a system-context insert that carries a userId and omits delegator_id (refused by required before, stored with that userId after). That is an accept-set change the claim's Clause-2 line does not cover, and the picker filter alone makes the form offer only what the guard admits, so the default is dropped here and raised as a decision in the report. What a write may store is now unchanged. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- ...proval-delegation-delegator-picker.test.ts | 54 ++++++------------- .../src/sys-approval-delegation.object.ts | 20 +++---- 2 files changed, 23 insertions(+), 51 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts index b3d41b28ddb..e1439fee93d 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts @@ -1,42 +1,35 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The Delegator field of `sys_approval_delegation` offers exactly what the + * The Delegator picker of `sys_approval_delegation` offers exactly what the * delegation write guard admits. * * `bindDelegationWriteGuard` (lifecycle-hooks.ts) admits one delegator on a * non-system write — the acting user — and refuses every other with 403: a * delegation names its own author as delegator, and acting for an absent * approver is the approval-level override (the guard's docstring holds the - * ruling). The field used to be a free `sys_user` lookup, so the console's - * create dialog demanded a choice among every user and the server refused all - * but one of them. It now declares that one value, in the object vocabulary - * the create form honours: - * - * - `defaultValue: 'current_user'` — the create form seeds the acting user - * (the token the console resolves client-side on a `sys_user` lookup), and - * an insert that omits the field stores the acting user; - * - `lookupFilters` on `id` = `{current_user_id}` — the picker's candidate - * query, whose placeholder the engine resolves against the caller on every - * read. + * ruling). The field was a free `sys_user` lookup, so the console's create + * dialog offered every user and the server refused all but one of them. It + * now declares `lookupFilters` on `id` = `{current_user_id}`: the picker's + * candidate query, whose placeholder the engine resolves against the caller + * on every read. * * Through a real `ObjectQL` over `SqlDriver` (better-sqlite3, in-memory, real * DDL) with the guard bound, this file asserts: * - * 1. the declaration, read off the object; + * 1. the declaration, read off the object and off the registered body; * 2. the picker's candidate query returns the caller and nobody else, for * each caller; - * 3. every candidate it offers is admitted by the guard, and a create that - * leaves the field empty stores the caller; + * 3. every candidate it offers is admitted by the guard; * 4. the guard is not widened: a create or an update naming anyone else is * still refused, `FORBIDDEN` / 403, and nothing is stored; - * 5. `required` stays: a write with no acting user still has no delegator. + * 5. `required` stays: a write with no acting user and no delegator is + * refused. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { isCurrentUserDefaultToken } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { bindDelegationWriteGuard } from './lifecycle-hooks.js'; @@ -44,7 +37,7 @@ const OBJECT = 'sys_approval_delegation'; const SYSTEM = { context: { isSystem: true } } as any; const asUser = (userId: string) => ({ context: { userId, positions: [], permissions: [] } }) as any; -/** The install's people: an admin covering for Priya, and two colleagues. */ +/** The install's people: an admin covering for Priya, and a colleague. */ const USERS = [ { id: 'u_admin', name: 'Dev Admin' }, { id: 'u_priya', name: 'Priya Raman' }, @@ -69,8 +62,8 @@ const delegatorField = (): Record => /** * The picker's candidate `where`, from the declared `lookupFilters`. The * console lowers each `eq` entry to `{ [field]: value }` and sends it as the - * `$filter` of its `sys_user` query (objectui `lookupFiltersToRecord`); every - * entry here is `eq`, which the declaration test holds. + * `$filter` of its `sys_user` query (objectui `lookupFiltersToRecord`); the + * declaration test holds that every entry here is `eq`. */ function pickerWhere(): Record { const filters = (delegatorField().lookupFilters ?? []) as LookupFilter[]; @@ -79,7 +72,7 @@ function pickerWhere(): Record { return where; } -describe('sys_approval_delegation — the Delegator field offers only what the write guard admits', () => { +describe('sys_approval_delegation — the Delegator picker offers only what the write guard admits', () => { let engine: ObjectQL; beforeAll(async () => { @@ -102,20 +95,16 @@ describe('sys_approval_delegation — the Delegator field offers only what the w try { await engine?.destroy(); } catch { /* noop */ } }); - it('declares the acting user as the default and as the picker\'s only candidate, and stays required', () => { + it('declares the acting user as the picker\'s only candidate, and stays a required sys_user lookup', () => { const field = delegatorField(); expect(field.type).toBe('lookup'); expect(field.reference).toBe('sys_user'); expect(field.required).toBe(true); - // The token the console's create form resolves to the acting user on a - // `sys_user` lookup, and the engine stamps at insert. - expect(isCurrentUserDefaultToken(field.defaultValue)).toBe(true); expect(field.lookupFilters).toEqual([{ field: 'id', operator: 'eq', value: '{current_user_id}' }]); }); - it('the registered body keeps both declarations', () => { + it('the registered body keeps the declaration', () => { const registered = (engine.registry.getObject(OBJECT) as any).fields.delegator_id; - expect(isCurrentUserDefaultToken(registered.defaultValue)).toBe(true); expect(registered.lookupFilters).toEqual(delegatorField().lookupFilters); }); @@ -135,15 +124,6 @@ describe('sys_approval_delegation — the Delegator field offers only what the w } }); - it.each([ - ['omitted', { delegate_id: 'u_tomas' }], - ['null', { delegator_id: null, delegate_id: 'u_tomas' }], - ])('a create that leaves the delegator %s stores the acting user', async (_label, data) => { - const created = await engine.insert(OBJECT, { ...data }, asUser('u_admin')); - const row = await engine.findOne(OBJECT, { where: { id: created.id } } as any, SYSTEM); - expect(row?.delegator_id).toBe('u_admin'); - }); - it('the guard is not widened: a create naming anyone else is refused FORBIDDEN / 403 and stores nothing', async () => { const before = await engine.count(OBJECT, { where: { delegator_id: 'u_priya' } } as any, SYSTEM); await expect( @@ -153,7 +133,7 @@ describe('sys_approval_delegation — the Delegator field offers only what the w }); it('the guard is not widened: an update relabelling the delegator is refused FORBIDDEN / 403', async () => { - const own = await engine.insert(OBJECT, { delegate_id: 'u_tomas' }, asUser('u_admin')); + const own = await engine.insert(OBJECT, { delegator_id: 'u_admin', delegate_id: 'u_tomas' }, asUser('u_admin')); await expect( engine.update(OBJECT, { id: own.id, delegator_id: 'u_priya' }, asUser('u_admin')), ).rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index 9cb66fd9eff..fc7b28fbd9d 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -89,23 +89,15 @@ export const SysApprovalDelegation = ObjectSchema.create({ // A delegation names its own author as delegator: on every non-system // write `bindDelegationWriteGuard` (lifecycle-hooks.ts) admits exactly one // value here, the acting user, and refuses any other with 403 (its - // docstring holds why there is no admin exemption). So the field declares - // that one value in the vocabulary the create form honours, instead of a - // free `sys_user` lookup that offered every user and saw all but one - // refused: - // - `defaultValue: 'current_user'` — the create form opens on the acting - // user, and an insert that omits the field stores the acting user - // (`applyFieldDefaults`, before the guard runs; the guard stamps the - // same value). `required` stays: a write with no acting user still has - // no delegator, and is refused. - // - `lookupFilters` — the picker's candidate query, which the engine - // resolves `{current_user_id}` in for every read, so it offers the - // acting user and nobody else. - // Pinned in `sys-approval-delegation-delegator-picker.test.ts`. + // docstring holds why there is no admin exemption). So the picker offers + // that one value, instead of every user with all but one refused: + // `lookupFilters` is the picker's candidate query, and the engine resolves + // `{current_user_id}` against the caller on every read. Presentation only + // — the guard stays the enforcement, and what a write may store is + // unchanged. Pinned in `sys-approval-delegation-delegator-picker.test.ts`. delegator_id: Field.lookup('sys_user', { label: 'Delegator', required: true, - defaultValue: 'current_user', lookupFilters: [{ field: 'id', operator: 'eq', value: '{current_user_id}' }], group: 'Delegation', description: 'The user going out of office; their individually-routed approver slots are rerouted while active.', From 02ce60a0cc86fea6b574df7eab685db91014a432 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:47:10 +0000 Subject: [PATCH 3/4] test(plugin-approvals): pin the registered picker filter literally; add the changeset The registered-body pin compared the registered field with the source field, so dropping the filter left both undefined and the pin green. It now compares with the literal filter. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24764-delegation-delegator-picker.md | 13 +++++++++++++ ...sys-approval-delegation-delegator-picker.test.ts | 7 +++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .changeset/24764-delegation-delegator-picker.md diff --git a/.changeset/24764-delegation-delegator-picker.md b/.changeset/24764-delegation-delegator-picker.md new file mode 100644 index 00000000000..711dcfe3a2e --- /dev/null +++ b/.changeset/24764-delegation-delegator-picker.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +fix(plugin-approvals): the out-of-office delegation form's Delegator picker offers only the signed-in user, the one delegator the server accepts + +Clause-②: no + +No accept set moves. The field gains a picker filter, which nothing on the write path reads, and the delegation write guard and `required` are unchanged. + +- **Before.** `sys_approval_delegation.delegator_id` was a free `sys_user` lookup, so Approvals → Delegations → New offered every user. The delegation write guard accepts one delegator on a non-system write, the acting user, so picking anyone else was refused with 403 `FORBIDDEN`. +- **Now.** The field declares `lookupFilters: [{ field: 'id', operator: 'eq', value: '{current_user_id}' }]`. The engine resolves the placeholder against the caller on every read, so the picker's candidate query offers the signed-in user and nobody else. +- **Unchanged.** What a write may store. A create or an update that names another delegator is still refused, and there is no admin exemption. An admin covering for an absent approver reassigns, recalls or rejects that approver's pending approvals through the approval override instead. diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts index e1439fee93d..31a6ffaf32d 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts @@ -56,6 +56,9 @@ const sysUser = { type LookupFilter = { field: string; operator: string; value: unknown }; +/** The one candidate the guard admits: the acting user, by id. */ +const ACTING_USER_ONLY: LookupFilter[] = [{ field: 'id', operator: 'eq', value: '{current_user_id}' }]; + const delegatorField = (): Record => (SysApprovalDelegation.fields as Record).delegator_id; @@ -100,12 +103,12 @@ describe('sys_approval_delegation — the Delegator picker offers only what the expect(field.type).toBe('lookup'); expect(field.reference).toBe('sys_user'); expect(field.required).toBe(true); - expect(field.lookupFilters).toEqual([{ field: 'id', operator: 'eq', value: '{current_user_id}' }]); + expect(field.lookupFilters).toEqual(ACTING_USER_ONLY); }); it('the registered body keeps the declaration', () => { const registered = (engine.registry.getObject(OBJECT) as any).fields.delegator_id; - expect(registered.lookupFilters).toEqual(delegatorField().lookupFilters); + expect(registered.lookupFilters).toEqual(ACTING_USER_ONLY); }); it.each(USERS.map((u) => [u.id]))('the picker offers %s themselves and nobody else', async (caller) => { From 529f78b82c2d1dcb7ed967448e37acfcb1b05f73 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:34:05 +0000 Subject: [PATCH 4/4] test(plugin-approvals): type the delegator-picker test's engine query options check:query-options-erasure counted five untyped option bags in the new test (236 to 241 test sites). They are EngineQueryOptions and EngineCountOptions now, and the ratchet holds at 236. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- ...proval-delegation-delegator-picker.test.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts index 31a6ffaf32d..8b295845603 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts @@ -30,6 +30,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import type { EngineCountOptions, EngineQueryOptions } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { bindDelegationWriteGuard } from './lifecycle-hooks.js'; @@ -63,18 +64,23 @@ const delegatorField = (): Record => (SysApprovalDelegation.fields as Record).delegator_id; /** - * The picker's candidate `where`, from the declared `lookupFilters`. The + * The picker's candidate query, from the declared `lookupFilters`. The * console lowers each `eq` entry to `{ [field]: value }` and sends it as the * `$filter` of its `sys_user` query (objectui `lookupFiltersToRecord`); the * declaration test holds that every entry here is `eq`. */ -function pickerWhere(): Record { +function pickerQuery(): EngineQueryOptions { const filters = (delegatorField().lookupFilters ?? []) as LookupFilter[]; const where: Record = {}; for (const f of filters) where[f.field] = f.value; - return where; + return { where }; } +/** The delegations that name Priya as delegator. */ +const PRIYA_AS_DELEGATOR: EngineCountOptions = { where: { delegator_id: 'u_priya' } }; + +const byId = (id: string): EngineQueryOptions => ({ where: { id } }); + describe('sys_approval_delegation — the Delegator picker offers only what the write guard admits', () => { let engine: ObjectQL; @@ -112,12 +118,12 @@ describe('sys_approval_delegation — the Delegator picker offers only what the }); it.each(USERS.map((u) => [u.id]))('the picker offers %s themselves and nobody else', async (caller) => { - const candidates = await engine.find('sys_user', { where: pickerWhere() } as any, asUser(caller)); + const candidates = await engine.find('sys_user', pickerQuery(), asUser(caller)); expect(candidates.map((r: any) => r.id)).toEqual([caller]); }); it('every candidate the picker offers is admitted by the guard', async () => { - const candidates = await engine.find('sys_user', { where: pickerWhere() } as any, asUser('u_admin')); + const candidates = await engine.find('sys_user', pickerQuery(), asUser('u_admin')); expect(candidates.length).toBeGreaterThan(0); for (const candidate of candidates) { const created = await engine.insert( @@ -128,11 +134,11 @@ describe('sys_approval_delegation — the Delegator picker offers only what the }); it('the guard is not widened: a create naming anyone else is refused FORBIDDEN / 403 and stores nothing', async () => { - const before = await engine.count(OBJECT, { where: { delegator_id: 'u_priya' } } as any, SYSTEM); + const before = await engine.count(OBJECT, PRIYA_AS_DELEGATOR, SYSTEM); await expect( engine.insert(OBJECT, { delegator_id: 'u_priya', delegate_id: 'u_tomas' }, asUser('u_admin')), ).rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 }); - expect(await engine.count(OBJECT, { where: { delegator_id: 'u_priya' } } as any, SYSTEM)).toBe(before); + expect(await engine.count(OBJECT, PRIYA_AS_DELEGATOR, SYSTEM)).toBe(before); }); it('the guard is not widened: an update relabelling the delegator is refused FORBIDDEN / 403', async () => { @@ -140,7 +146,7 @@ describe('sys_approval_delegation — the Delegator picker offers only what the await expect( engine.update(OBJECT, { id: own.id, delegator_id: 'u_priya' }, asUser('u_admin')), ).rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 }); - const row = await engine.findOne(OBJECT, { where: { id: own.id } } as any, SYSTEM); + const row = await engine.findOne(OBJECT, byId(own.id), SYSTEM); expect(row?.delegator_id).toBe('u_admin'); });