diff --git a/.changeset/24639-hook-seed-email.md b/.changeset/24639-hook-seed-email.md new file mode 100644 index 00000000000..e059e1a845a --- /dev/null +++ b/.changeset/24639-hook-seed-email.md @@ -0,0 +1,18 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): a `runAs: 'user'` hook now carries the acting user's email, so a `current_user.email` row policy admits it as it admits the same user's own request + +A hook declaring `runAs: 'user'` is pinned to the triggering user, and the pinned context is seeded with the user's grants and organization peer list through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`). That seed never took the user's email, although the same resolution returns it. When the triggering write carried a hand-built system context such as `{ isSystem: true, userId, tenantId }` (the approvals status mirror and a `runAs: 'system'` flow's writes are shipped producers), the pinned context carried no `email`. Every row policy keyed on `current_user.email` (for example `assignee == current_user.email`) therefore dropped out, and the hook's `ctx.api` reads returned no rows where the same user's request returns theirs. It failed closed: rows were hidden, never leaked. + +The seed now carries the email too: + +- A pinned context with no `email` takes the user's stored email (`sys_user.email`), from the user-row read the resolution already makes, which is the fallback the request path uses. +- An email the context already carries, such as a session's on a request-resolved context, is kept exactly as it was. +- A context that carries `permissions` and a peer list but no email is no longer handed through unseeded: it resolves once and takes the email alone. Its positions, permission sets and peer list stay exactly as they were. +- A user with no stored email (a phone-only sign-up) still carries none, and email-keyed policies still drop out for them, as on their own request. + +`runAs: 'inherit'` (the default) and `runAs: 'system'` are unchanged, and a `runAs: 'user'` hook whose trigger names no user is still refused with `HOOK_UNSCOPED_DATA_ACCESS`. A `runAs: 'user'` hook fired from a context that lacks the email now pays one grants resolution per dispatch, as one that lacks either list already did. If the resolver fails, the hook runs on the unseeded pin and a warning names the policies that drop out. + +Clause-②: no diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1ab84f10b83..a2c16cc04bf 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -147,7 +147,7 @@ import { // Layer 0 — see {@link buildDriverOptions}. computeTenantLayer0Verdict, // [#24497] The ONE grants resolver, the one the request path seeds a - // context's grants and peer list through — see `ScopedContext[SEED_USER_GRANTS]`. + // context's grants, peer list and email (#24639) through — see `ScopedContext[SEED_USER_GRANTS]`. resolveUserAuthzGrants, } from '@objectstack/core'; import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; @@ -21840,8 +21840,9 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { /** * [#24497] The second half of a hook's `runAs: 'user'` derivation: the - * user's grants and peer list, seeded through the one resolver the request - * path seeds them through (`resolveUserAuthzGrants`, `@objectstack/core`). + * user's grants, peer list and email (#24639), seeded through the one + * resolver the request path seeds them through (`resolveUserAuthzGrants`, + * `@objectstack/core`). * The hook wrapper awaits it on the api {@link withRunAs} derived; it stays * a separate, async step so `withRunAs` keeps its synchronous contract. * @@ -21852,13 +21853,31 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * user's request saw their organisation's members too. Ruling A′ puts the * contract on the producer: every user context carries `org_user_ids`. * + * [#24639] The same contract holds for `email`: the RLS compiler binds + * `current_user.email` from the context's `email` and nothing else, so a + * hand-built context that named a user and carried no email made every + * email-keyed row policy (`assignee == current_user.email`) drop out, and + * the hook read nothing where the user's own request reads their rows. The + * email is the one the same resolution returns (the user's stored + * `sys_user.email`, from the user-row read that resolution already makes, + * as the request path falls back to it), so a context that resolves for its + * grants or peers pays nothing more for it. A context that lacks only the + * email pays the one resolution; a user with no stored email (a phone-only + * sign-up) pays it on every such dispatch and still carries none, which + * fails closed exactly as before. + * * The same rule as a `runAs: 'user'` flow's run setup (service-automation * `resolveRunContext`): * - positions and permission sets the context already carries stay * verbatim, so a narrowed ceiling is never re-broadened; only a context * with no `permissions` takes the resolved ones; * - a context that already carries a peer list keeps it; - * - a context that needs neither resolves nothing and is returned as is. + * - a context that already carries an email keeps it verbatim (the + * session's, on a request-resolved context); only a context with none + * takes the resolved one; + * - a context that needs none of the three resolves nothing and is + * returned as is. One that lacks only the email is NOT that context: it + * resolves, and takes the email alone. * * The peer list is the members of the context's `tenantId`, the user * included; a context with no `tenantId` gets the user alone, as an @@ -21877,7 +21896,8 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { if (!ctx.userId) return this; const needsGrants = !Array.isArray(ctx.permissions); const needsPeers = !Array.isArray(ctx.org_user_ids); - if (!needsGrants && !needsPeers) return this; + const needsEmail = !ctx.email; + if (!needsGrants && !needsPeers && !needsEmail) return this; const grants = await resolveUserAuthzGrants( this.engine, ctx.userId, @@ -21888,6 +21908,7 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { ...ctx, ...(needsGrants ? { positions: grants.positions, permissions: grants.permissions } : {}), ...(needsPeers ? { org_user_ids: grants.org_user_ids } : {}), + ...(needsEmail && grants.email ? { email: grants.email } : {}), }, this.engine ); diff --git a/packages/objectql/src/hook-run-as.test.ts b/packages/objectql/src/hook-run-as.test.ts index 3fa5578d3e7..dc0557cb136 100644 --- a/packages/objectql/src/hook-run-as.test.ts +++ b/packages/objectql/src/hook-run-as.test.ts @@ -41,6 +41,7 @@ import { HookUnscopedDataAccessError, HOOK_UNSCOPED_DATA_ACCESS_CODE, HOOK_UNSCOPED_DATA_ACCESS_STATUS, + SEED_USER_GRANTS, hookRunAs, } from './hook-run-as.js'; import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; @@ -62,6 +63,9 @@ const OPERATOR: ExecutionContext = { /** A trigger that resolved NO user: an `isSystem` service write, a system flow node. */ const USERLESS: ExecutionContext = { isSystem: true, positions: [], permissions: [] } as ExecutionContext; +/** The email `usr_operator`'s stored `sys_user` row carries in the grants-answering engine below (#24639). */ +const STORED_EMAIL = 'operator@example.test'; + // --------------------------------------------------------------------------- // Layer 1 — the wrapper over a real ScopedContext whose engine records. // --------------------------------------------------------------------------- @@ -286,7 +290,8 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () => describe("[#24497] a 'user' pin carries the user's grants and peer list, through the request path's resolver", () => { /** * A recording engine that also answers the reads `resolveUserAuthzGrants` - * issues: organisation `org_1` holds `usr_operator` and `usr_peer`. + * issues: organisation `org_1` holds `usr_operator` and `usr_peer`, and + * `usr_operator`'s stored `sys_user` row carries {@link STORED_EMAIL}. * `failReads` makes every read throw, as an unreadable permission store does. */ function grantsEngine(opts: { failReads?: boolean } = {}) { @@ -294,6 +299,9 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () => engine.find = async (object: string, query: any) => { engine.seen.push({ method: 'find', context: query?.context, object, where: query?.where }); if (opts.failReads) throw new Error('permission store unreachable'); + if (object === 'sys_user' && query?.where?.id === 'usr_operator') { + return [{ id: 'usr_operator', email: STORED_EMAIL }]; + } if (object === 'sys_member' && query?.where?.organization_id === 'org_1') { return [ { id: 'm1', user_id: 'usr_operator', organization_id: 'org_1', role: 'member' }, @@ -340,12 +348,17 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () => }); it('a context that already carries a peer list (a request-resolved one) resolves NOTHING and keeps its envelope', async () => { + // A request-resolved context carries the email the request path seeded + // (`resolveAuthzContext`), so it needs nothing (#24639). const engine = grantsEngine(); - const context = await hookWrite({ ...OPERATOR, org_user_ids: ['usr_operator', 'usr_x'] } as ExecutionContext, engine); + const context = await hookWrite({ + ...OPERATOR, org_user_ids: ['usr_operator', 'usr_x'], email: 'session@example.test', + } as ExecutionContext, engine); expect(reads(engine)).toEqual([]); expect(context.org_user_ids).toEqual(['usr_operator', 'usr_x']); expect(context.positions).toEqual(OPERATOR.positions); expect(context.permissions).toEqual(OPERATOR.permissions); + expect(context.email).toBe('session@example.test'); }); it('a trigger with no organisation: the peer list is the user alone, as an organisation-less request\'s is', async () => { @@ -387,7 +400,48 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () => expect(context.isSystem).toBe(false); expect(context.org_user_ids).toBeUndefined(); expect(warns).toHaveLength(1); - expect(String(warns[0]![0])).toMatch(/current_user\.org_user_ids drops out/); + // The variables whose policies drop out are named (#24639 adds the email). + expect(String(warns[0]![0])).toMatch(/current_user\.org_user_ids/); + expect(String(warns[0]![0])).toMatch(/current_user\.email/); + }); + + describe("[#24639] the pin carries the user's email too, so a current_user.email row policy matches", () => { + it("a hand-built { isSystem: true, userId, tenantId } trigger: the pin carries the email the user's sys_user row stores", async () => { + const engine = grantsEngine(); + const context = await hookWrite({ isSystem: true, userId: 'usr_operator', tenantId: 'org_1' } as ExecutionContext, engine); + expect(context.isSystem).toBe(false); + expect(context.email).toBe(STORED_EMAIL); + // The email rides the resolution the grants already make: ONE read of the user row. + expect(reads(engine).filter((r: any) => r.object === 'sys_user')).toHaveLength(1); + }); + + it('an email the context already carries is kept VERBATIM, never overwritten by the stored one', async () => { + const engine = grantsEngine(); + const context = await hookWrite({ + isSystem: true, userId: 'usr_operator', tenantId: 'org_1', email: 'carried@example.test', + } as ExecutionContext, engine); + expect(context.email).toBe('carried@example.test'); + // It still needed its grants and peers, and took them. + expect([...(context.org_user_ids ?? [])].sort()).toEqual(['usr_operator', 'usr_peer']); + }); + + it('a context that carries positions, permission sets and a peer list but NO email is not returned early: it takes the email, and only the email', async () => { + const engine = grantsEngine(); + const context = await hookWrite({ + ...OPERATOR, isSystem: true, org_user_ids: ['usr_operator', 'usr_x'], + } as ExecutionContext, engine); + expect(context.email).toBe(STORED_EMAIL); + expect(context.org_user_ids).toEqual(['usr_operator', 'usr_x']); + expect(context.positions).toEqual(OPERATOR.positions); + expect(context.permissions).toEqual(OPERATOR.permissions); + }); + + it('a context with no userId is returned untouched: nothing to seed, nothing read', async () => { + const engine = grantsEngine(); + const api = new ScopedContext({ isSystem: false, tenantId: 'org_1' } as ExecutionContext, engine); + expect(await api[SEED_USER_GRANTS]()).toBe(api); + expect(reads(engine)).toEqual([]); + }); }); }); diff --git a/packages/objectql/src/hook-run-as.ts b/packages/objectql/src/hook-run-as.ts index c0f5b25f849..d7c3529fb7d 100644 --- a/packages/objectql/src/hook-run-as.ts +++ b/packages/objectql/src/hook-run-as.ts @@ -208,8 +208,8 @@ export function deriveHookApi(api: unknown, runAs: HookRunAs, ref: HookRunAsRef) /** * The key the engine's `ScopedContext` carries the second half of a - * `runAs: 'user'` derivation under (#24497): seed the user's grants and peer - * list onto the api `withRunAs('user')` derived. + * `runAs: 'user'` derivation under (#24497): seed the user's grants, peer + * list and email (#24639) onto the api `withRunAs('user')` derived. * * A symbol, and ⛔ deliberately NOT re-exported from the package entry, so the * method is no NAMEABLE member of `ScopedContext`'s published face: only the @@ -222,7 +222,7 @@ export const SEED_USER_GRANTS: unique symbol = Symbol('objectstack.objectql.hook /** * What the api a `runAs: 'user'` derivation produced must offer for the - * user's grants and peer list to be seeded onto it (#24497). The engine's + * user's grants, peer list and email to be seeded onto it (#24497). The engine's * `ScopedContext` implements it (`engine.ts`, under {@link SEED_USER_GRANTS}); * the refusing {@link UnscopedHookApi} does not, and needs nothing seeded. */ @@ -231,9 +231,9 @@ interface UserGrantsSeedableApi { } /** - * Seed the user's grants and peer list onto the api a `runAs: 'user'` + * Seed the user's grants, peer list and email onto the api a `runAs: 'user'` * derivation produced, through the core resolver the request path uses - * (#24497). Async, so it is a step of its own after {@link deriveHookApi} + * (#24497, #24639). Async, so it is a step of its own after {@link deriveHookApi} * rather than a change to that function's synchronous contract. An api that * cannot seed (the refusing {@link UnscopedHookApi}) is returned as is. */ diff --git a/packages/objectql/src/hook-wrappers.ts b/packages/objectql/src/hook-wrappers.ts index 6a2d4695077..d89e3d5c963 100644 --- a/packages/objectql/src/hook-wrappers.ts +++ b/packages/objectql/src/hook-wrappers.ts @@ -557,22 +557,25 @@ function installRunAsApi( }; }; if (runAs !== 'user') return Promise.resolve(install(derived)); - // [#24497] A `'user'` pin carries the user's grants and peer list, seeded - // through the resolver the request path uses (`ScopedContext[SEED_USER_GRANTS]`, `hook-run-as.ts`). - // Cost: one `resolveUserAuthzGrants` per dispatch whose triggering context - // carries no `permissions` or no `org_user_ids` — the hand-built system - // contexts; a request-resolved context carries both and resolves nothing. + // [#24497] A `'user'` pin carries the user's grants, peer list and email + // (#24639), seeded through the resolver the request path uses + // (`ScopedContext[SEED_USER_GRANTS]`, `hook-run-as.ts`). Cost: one + // `resolveUserAuthzGrants` per dispatch whose triggering context carries no + // `permissions`, no `org_user_ids` or no `email` — the hand-built system + // contexts; a request-resolved context carries all three and resolves + // nothing (unless its user stores no email: then every such dispatch resolves). return seedHookUserGrants(derived).then(install, (err: unknown) => { // Fail-safe, never fail-open — the flow run setup's posture: the hook keeps // the unseeded pin, so its data ops run on the context's own grants (the // security plugin's member baseline when it carries none, never elevation), - // and every row policy reading `current_user.org_user_ids` drops out. It - // sees less, never more. FUNCTIONAL, so `warn`: nothing claimed-persisted + // and every row policy reading a value it does not carry + // (`current_user.org_user_ids`, `current_user.email`) drops out. It sees + // less, never more. FUNCTIONAL, so `warn`: nothing claimed-persisted // failed to land, and the symptom is the hook's own narrower reads. logger.warn( `[hook] runAs 'user': the triggering user's grants could not be resolved, so this hook's data ops run ` + - `on the triggering context's own grants (not elevated), and every row policy reading ` + - `current_user.org_user_ids drops out`, + `on the triggering context's own grants (not elevated), and every row policy reading a value that ` + + `context does not carry (current_user.email, current_user.org_user_ids) drops out`, { hook: hookName, object: ctx.object, event: ctx.event, error: (err as any)?.message }, ); return install(derived); diff --git a/packages/services/service-automation/src/runas-user-hook-email-owner.integration.test.ts b/packages/services/service-automation/src/runas-user-hook-email-owner.integration.test.ts new file mode 100644 index 00000000000..b936084b3f6 --- /dev/null +++ b/packages/services/service-automation/src/runas-user-hook-email-owner.integration.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `runAs:'user'` HOOK reads the rows an email-keyed row policy grants its + * user, exactly as that user's own request does. The hook-side twin of + * `runas-user-email-owner.integration.test.ts` (the flow side), and the email + * twin of `runas-user-hook-org-peers.integration.test.ts` (the peer list). + * + * The defect this pins: a hook fired from a hand-built + * `{ isSystem: true, userId, tenantId }` context is de-elevated by + * `ScopedContext.withRunAs('user')` and then seeded by + * `ScopedContext[SEED_USER_GRANTS]` (objectql). That seed took the user's + * positions, permission sets and peer list from the core resolver the request + * path uses (`resolveUserAuthzGrants`), but never the email that same + * resolution returns. The RLS compiler binds `current_user.email` from the + * execution context's `email` and nothing else, so a policy such as + * `assignee == current_user.email` (the stock showcase's `task_own_rows`) + * dropped out inside the hook, and the hook read no rows where its user's own + * request reads theirs. It failed closed: rows hidden, never leaked. + * + * It lives here, beside its twins, because this package is the one that + * already composes the whole stack under test in its dev dependencies: the + * ObjectQL engine, driver-sql on better-sqlite3 `:memory:`, the identity + * objects and the real SecurityPlugin with its shipped default permission sets + * plus the fixture's set. No automation runs here. + * + * Fixture: `qa_hook_owner_task`, whose only row scope is the set + * `qa_hook_owner_reader`: select keyed on `assignee == current_user.email`. + * `usr_member` and `usr_other` hold the set in the Default Organization, each + * with their own stored email, and one task is assigned to each. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, resolveUserAuthzGrants, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, bindHooksToEngine, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +const SYS = { isSystem: true } as const; +const OBJECT = 'qa_hook_owner_task'; +const MEMBER = 'usr_member'; +const OTHER = 'usr_other'; +const EMAIL: Record = { + [MEMBER]: 'qa-hook-member@example.test', + [OTHER]: 'qa-hook-other@example.test', +}; +const silentLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; + +/** The showcase task's shape, cut to the fields the policy reads. */ +const taskObject = { + name: OBJECT, + label: 'Hook owner task', + sharingModel: 'public_read_write', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + assignee: { name: 'assignee', label: 'Assignee', type: 'text' }, + }, +}; + +/** The object whose insert fires the hook under test. */ +const probeObject = { + name: 'hook_probe', + label: 'Hook probe', + // Platform-global, so every firing below is a legal system write (ADR-0066). + tenancy: { enabled: false }, + fields: { name: { name: 'name', label: 'Name', type: 'text' } }, +}; + +/** `showcase_contributor`'s `task_own_rows`, on the one verb the hook uses. */ +const readerSet = PermissionSetSchema.parse({ + name: 'qa_hook_owner_reader', + label: 'Hook owner task reader', + objects: { [OBJECT]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } }, + rowLevelSecurity: [ + { name: 'qa_hook_task_own_rows', object: OBJECT, operation: 'select', using: 'assignee == current_user.email' }, + ], +}); + +describe("a runAs:'user' hook reads the rows an email-keyed policy grants its user, as the user's own request does", () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let orgA = ''; + /** The task titles the hook's read answered, per firing. */ + const seen: string[][] = []; + /** The context the hook's read carried to the middleware chain, per firing. */ + const seenContexts: Array> = []; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', type: 'standard', version: '1.0.0', + async init(ctx: PluginContext) { ctx.registerService('driver.default', driver); }, + }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, readerSet] })); + await kernel.bootstrap(); + + ql = kernel.getService('objectql'); + ql.registry.registerObject(taskObject as any, 'hook-email-owner-test', 'hook-email-owner-test'); + ql.registry.registerObject(probeObject as any, 'hook-email-owner-test', 'hook-email-owner-test'); + await ql.syncSchemas(); + + const orgs = await ql.find('sys_organization', { context: SYS }); + expect(orgs.map((o: any) => o.slug)).toEqual(['default']); + orgA = String(orgs[0].id); + for (const id of [MEMBER, OTHER]) { + await ql.insert('sys_user', { id, name: id, email: EMAIL[id] }, { context: SYS }); + await ql.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: orgA, role: 'member' }, { context: SYS }); + await ql.insert('sys_user_permission_set', { id: `ups_${id}`, user_id: id, permission_set: readerSet.name, organization_id: orgA }, { context: SYS }); + } + await ql.insert(OBJECT, { title: 'member task', assignee: EMAIL[MEMBER] }, { context: SYS }); + await ql.insert(OBJECT, { title: 'other task', assignee: EMAIL[OTHER] }, { context: SYS }); + + let inHook = false; + ql.registerMiddleware(async (opCtx: any, next: any) => { + if (inHook && opCtx.object === OBJECT) seenContexts.push({ ...(opCtx.context ?? {}) }); + return next(); + }); + bindHooksToEngine(ql, [ + { + name: 'read_own_tasks', + object: 'hook_probe', + events: ['afterInsert'], + priority: 100, + runAs: 'user', + handler: async (ctx: any) => { + inHook = true; + try { + seen.push(titles(await ctx.api.object(OBJECT).find({ fields: ['id', 'title'] }))); + } finally { + inHook = false; + } + }, + } as any, + ], { packageId: 'app:test', logger: silentLogger }); + }, 30_000); + + afterAll(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + const titles = (rows: unknown): string[] => (Array.isArray(rows) ? rows.map((r: any) => String(r.title)).sort() : []); + + /** + * The context the request path hands ObjectQL for `userId` acting in the + * organization: `resolveAuthzContext` copies exactly these grants onto it, + * the email included (`ctx.email`, the session's or else the stored one). + */ + async function requestContext(userId: string, opts: { email?: boolean } = {}) { + const g = await resolveUserAuthzGrants(ql, userId, { tenantId: orgA, bypassGrantsCache: true }); + return { + userId, + tenantId: orgA, + isSystem: false, + positions: g.positions, + permissions: g.permissions, + systemPermissions: g.systemPermissions, + org_user_ids: g.org_user_ids, + accessible_org_ids: g.accessible_org_ids, + ...(opts.email === false || !g.email ? {} : { email: g.email }), + }; + } + + /** Fire the hook with `context` as the triggering write's context; answer what its read saw and carried. */ + async function fire(context: Record) { + const before = seen.length; + await ql.insert('hook_probe', { name: 'p' }, { context: context as any }); + expect(seen.length, 'the hook ran exactly once').toBe(before + 1); + return { rows: seen[seen.length - 1], hookCtx: seenContexts[seenContexts.length - 1] }; + } + + it("CONTROL — the member's own request sees the task assigned to their email and nothing beyond; without the email it sees nothing", async () => { + const req = await requestContext(MEMBER); + expect(req.email, 'the request path seeds the stored email').toBe(EMAIL[MEMBER]); + expect(titles(await ql.find(OBJECT, { fields: ['id', 'title'], context: req as any }))).toEqual(['member task']); + // GUARD: the email-keyed policy is the only access this fixture grants. + const noEmail = await requestContext(MEMBER, { email: false }); + expect(titles(await ql.find(OBJECT, { fields: ['id', 'title'], context: noEmail as any }))).toEqual([]); + }); + + it("the card's reproduction: fired from a hand-built { isSystem: true, userId, tenantId } context, the hook sees what the member's request sees", async () => { + const { rows, hookCtx } = await fire({ isSystem: true, userId: MEMBER, tenantId: orgA }); + expect(rows).toEqual(['member task']); + expect(hookCtx.isSystem).toBe(false); + expect(hookCtx.email, 'the pin carries the stored email').toBe(EMAIL[MEMBER]); + }); + + it('fired from the request-resolved envelope, the hook answers the same and keeps that envelope verbatim (control)', async () => { + const req = await requestContext(MEMBER); + // The triggering write is elevated only because the reader set grants no + // create on the probe object; the hook's runAs:'user' pins it back to + // `isSystem: false`, which is exactly the request context. + const { rows, hookCtx } = await fire({ ...req, isSystem: true }); + expect(rows).toEqual(['member task']); + expect(hookCtx.email).toBe(req.email); + expect(hookCtx.positions).toEqual(req.positions); + expect(hookCtx.permissions).toEqual(req.permissions); + expect(hookCtx.org_user_ids).toEqual(req.org_user_ids); + }); + + it('an email the triggering context already carries is kept VERBATIM, never overwritten by the stored one', async () => { + // A value deliberately different from `usr_member`'s stored email: the + // hook reads by the carried one, so it sees that one's task and not its own. + const { rows, hookCtx } = await fire({ isSystem: true, userId: MEMBER, tenantId: orgA, email: EMAIL[OTHER] }); + expect(hookCtx.email).toBe(EMAIL[OTHER]); + expect(rows).toEqual(['other task']); + }); + + it('a context that already carries positions, permission sets and a peer list but no email still gets the email, and keeps the rest verbatim', async () => { + const req = await requestContext(MEMBER, { email: false }); + const { rows, hookCtx } = await fire({ + isSystem: true, userId: MEMBER, tenantId: orgA, + positions: req.positions, permissions: req.permissions, org_user_ids: req.org_user_ids, + }); + expect(hookCtx.email).toBe(EMAIL[MEMBER]); + expect(rows).toEqual(['member task']); + expect(hookCtx.positions).toEqual(req.positions); + expect(hookCtx.permissions).toEqual(req.permissions); + expect(hookCtx.org_user_ids).toEqual(req.org_user_ids); + }); +});