|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#26180 · ruling 6429330267, letter A] Which records a pending approval step |
| 5 | + * lets a caller READ — `ApprovalService.pendingApproverReadGrants`, the answer |
| 6 | + * plugin-sharing ORs into its read filter. |
| 7 | + * |
| 8 | + * The full door (a real kernel, the security and sharing plugins, a two-step |
| 9 | + * flow) is pinned in dogfood's `approval-pending-approver-record-read`. What |
| 10 | + * only this level can state is the derivation itself and its bounds: |
| 11 | + * |
| 12 | + * - who: a PERSON on the pending slate — the caller's user id or account |
| 13 | + * email in a slot — and the override holder; a `position:` literal (a |
| 14 | + * target that resolved to nobody) names no person and grants no read; |
| 15 | + * - while: a decided request grants nothing, at the next read of the same |
| 16 | + * request context too (a write retires the memo); |
| 17 | + * - where: never across organizations, the platform administrator's override |
| 18 | + * included (that boot is `single`, one organization, no wall to cross); |
| 19 | + * - cost: a request context pays one account read and one index read for its |
| 20 | + * slate, and one request read per object — never a read per record, and a |
| 21 | + * caller on no slate stops after the index read; |
| 22 | + * - fail: a failed read is rethrown and never memoized. |
| 23 | + * |
| 24 | + * The rig is the read-gate suites' (`record-reader-opt-in.integration.test.ts`): |
| 25 | + * a real ObjectQL engine over better-sqlite3 and the real |
| 26 | + * `ApprovalsServicePlugin.start()`. |
| 27 | + */ |
| 28 | + |
| 29 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 30 | +import { ObjectQL } from '@objectstack/objectql'; |
| 31 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 32 | +import type { EngineQueryOptions } from '@objectstack/spec/data'; |
| 33 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 34 | +import { ApprovalService } from './approval-service.js'; |
| 35 | +import { ApprovalsServicePlugin } from './approvals-plugin.js'; |
| 36 | +import { SysApprovalRequest } from './sys-approval-request.object.js'; |
| 37 | +import { SysApprovalAction } from './sys-approval-action.object.js'; |
| 38 | +import { SysApprovalApprover } from './sys-approval-approver.object.js'; |
| 39 | +import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; |
| 40 | +import { DEFAULT_ORGANIZATION_ID, installDefaultOrganization } from './default-organization.testkit.js'; |
| 41 | + |
| 42 | +const OBJECT = 'contract_sheet'; |
| 43 | +const OTHER = 'memo_sheet'; |
| 44 | +const PKG = 'approvals-pending-approver-read-test'; |
| 45 | +const OTHER_ORG = 'org_other'; |
| 46 | + |
| 47 | +const SYSTEM: ExecutionContext = { isSystem: true, positions: [], permissions: [] }; |
| 48 | +/** A request row as the system reads it. */ |
| 49 | +const requestRow = async (rig: Rig, where: Record<string, unknown>): Promise<any> => |
| 50 | + ((await rig.engine.find('sys_approval_request', { where, context: SYSTEM } satisfies EngineQueryOptions)) as any[])[0]; |
| 51 | +/** A member of `org`, acting as themself. */ |
| 52 | +const member = (userId: string, org: string = DEFAULT_ORGANIZATION_ID) => |
| 53 | + ({ userId, tenantId: org, organizationId: org, positions: [], permissions: [], posture: 'MEMBER' }) as any; |
| 54 | + |
| 55 | +const SUBMITTER = 'u_submitter'; |
| 56 | +/** On the slate by user id. */ |
| 57 | +const BY_ID = 'u_by_id'; |
| 58 | +/** On the slate by the email their account carries. */ |
| 59 | +const BY_EMAIL = 'u_by_email'; |
| 60 | +const BY_EMAIL_ADDRESS = 'by.email@verify.test'; |
| 61 | +/** Holds position `buyer`; the slate holds only the unresolved `position:buyer` literal. */ |
| 62 | +const BUYER = 'u_buyer'; |
| 63 | +/** On no slate. */ |
| 64 | +const STRANGER = 'u_stranger'; |
| 65 | + |
| 66 | +function sheet(name: string) { |
| 67 | + return { |
| 68 | + name, |
| 69 | + label: name, |
| 70 | + fields: { |
| 71 | + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, |
| 72 | + title: { name: 'title', label: 'Title', type: 'text' as const }, |
| 73 | + }, |
| 74 | + }; |
| 75 | +} |
| 76 | + |
| 77 | +/** The account table `callerAccountEmail` reads. */ |
| 78 | +const SysUser = { |
| 79 | + name: 'sys_user', |
| 80 | + label: 'User', |
| 81 | + managedBy: 'better-auth', |
| 82 | + fields: { |
| 83 | + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, |
| 84 | + email: { name: 'email', label: 'Email', type: 'text' as const }, |
| 85 | + }, |
| 86 | +}; |
| 87 | + |
| 88 | +interface Rig { |
| 89 | + engine: ObjectQL; |
| 90 | + svc: ApprovalService; |
| 91 | + finds: string[]; |
| 92 | +} |
| 93 | + |
| 94 | +let current: ObjectQL | undefined; |
| 95 | + |
| 96 | +afterEach(async () => { |
| 97 | + try { await current?.destroy(); } catch { /* noop */ } |
| 98 | + current = undefined; |
| 99 | +}); |
| 100 | + |
| 101 | +async function boot(): Promise<Rig> { |
| 102 | + const engine = new ObjectQL(); |
| 103 | + current = engine; |
| 104 | + engine.registerDriver(new SqlDriver({ |
| 105 | + client: 'better-sqlite3', |
| 106 | + connection: { filename: ':memory:' }, |
| 107 | + useNullAsDefault: true, |
| 108 | + }), true); |
| 109 | + await engine.init(); |
| 110 | + for (const def of [ |
| 111 | + sheet(OBJECT), sheet(OTHER), SysUser, |
| 112 | + SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalDelegation, |
| 113 | + ]) { |
| 114 | + engine.registry.registerObject(def as any, PKG, PKG); |
| 115 | + } |
| 116 | + await engine.syncSchemas(); |
| 117 | + await installDefaultOrganization(engine); |
| 118 | + await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: OTHER_ORG, name: 'Other' }); |
| 119 | + await engine.getDriverForObject('sys_user')!.create('sys_user', { id: BY_EMAIL, email: BY_EMAIL_ADDRESS }); |
| 120 | + |
| 121 | + const services: Record<string, unknown> = { objectql: engine }; |
| 122 | + const ctx: any = { |
| 123 | + getService: (name: string) => { |
| 124 | + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); |
| 125 | + return services[name]; |
| 126 | + }, |
| 127 | + registerService: (name: string, svc: unknown) => { services[name] = svc; }, |
| 128 | + hook: () => {}, |
| 129 | + logger: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, |
| 130 | + }; |
| 131 | + await new ApprovalsServicePlugin().start(ctx); |
| 132 | + |
| 133 | + // Every engine read of the approval tables and the account table, by table — |
| 134 | + // the cost the read path pays. Counted at the engine's own `find`. |
| 135 | + const finds: string[] = []; |
| 136 | + const find = engine.find.bind(engine); |
| 137 | + (engine as any).find = (object: string, options?: EngineQueryOptions) => { |
| 138 | + if (object.startsWith('sys_approval') || object === 'sys_user') finds.push(object); |
| 139 | + return find(object, options); |
| 140 | + }; |
| 141 | + return { engine, svc: services.approvals as ApprovalService, finds }; |
| 142 | +} |
| 143 | + |
| 144 | +/** Open one pending request on `recordId` of `object`, its slate the given approver targets. */ |
| 145 | +async function open( |
| 146 | + rig: Rig, |
| 147 | + object: string, |
| 148 | + recordId: string, |
| 149 | + approvers: Array<{ type: 'user' | 'position'; value: string }>, |
| 150 | + organizationId: string = DEFAULT_ORGANIZATION_ID, |
| 151 | +): Promise<string> { |
| 152 | + await rig.engine.insert(object, { id: recordId, title: recordId, organization_id: organizationId }, { context: SYSTEM }); |
| 153 | + const opened = await rig.svc.openNodeRequest({ |
| 154 | + object, recordId, runId: `run_${recordId}`, nodeId: 'review', flowName: 'contract_review', |
| 155 | + config: { approvers, behavior: 'first_response' } as any, |
| 156 | + submitterId: SUBMITTER, record: { id: recordId, title: recordId }, organizationId, |
| 157 | + }, member(SUBMITTER, organizationId)) as any; |
| 158 | + expect(opened?.status, `request on ${recordId} is pending`).toBe('pending'); |
| 159 | + return String(opened.id); |
| 160 | +} |
| 161 | + |
| 162 | +/** The grant as plain data: record id → request ids. */ |
| 163 | +async function grantsOf(rig: Rig, object: string, context: any): Promise<Record<string, string[]>> { |
| 164 | + return Object.fromEntries([...(await rig.svc.pendingApproverReadGrants(object, context))].map(([k, v]) => [k, [...v]])); |
| 165 | +} |
| 166 | + |
| 167 | +describe('pendingApproverReadGrants — the records a pending approval step lets a caller read (#26180)', () => { |
| 168 | + it('grants a person on the pending slate, by user id or by account email, the one record — and no one else', async () => { |
| 169 | + const rig = await boot(); |
| 170 | + const byIdRequest = await open(rig, OBJECT, 'c_by_id', [{ type: 'user', value: BY_ID }]); |
| 171 | + const byEmailRequest = await open(rig, OBJECT, 'c_by_email', [{ type: 'user', value: BY_EMAIL_ADDRESS }]); |
| 172 | + await open(rig, OBJECT, 'c_position', [{ type: 'position', value: 'buyer' }]); |
| 173 | + |
| 174 | + expect(await grantsOf(rig, OBJECT, member(BY_ID))).toEqual({ c_by_id: [byIdRequest] }); |
| 175 | + expect(await grantsOf(rig, OBJECT, member(BY_EMAIL))).toEqual({ c_by_email: [byEmailRequest] }); |
| 176 | + // The submitter, a stranger: on no slate. |
| 177 | + expect(await grantsOf(rig, OBJECT, member(SUBMITTER))).toEqual({}); |
| 178 | + expect(await grantsOf(rig, OBJECT, member(STRANGER))).toEqual({}); |
| 179 | + // A `position:` literal names no person: the holder may decide it, but |
| 180 | + // the read is the resolved person's alone. |
| 181 | + const positionRow = await requestRow(rig, { record_id: 'c_position' }); |
| 182 | + expect(String(positionRow.pending_approvers)).toBe('position:buyer'); |
| 183 | + expect(await grantsOf(rig, OBJECT, { ...member(BUYER), positions: ['buyer'] })).toEqual({}); |
| 184 | + // Another object's records are never granted by this one's requests. |
| 185 | + expect(await grantsOf(rig, OTHER, member(BY_ID))).toEqual({}); |
| 186 | + // No user, a system context: nothing to grant (system reads are unscoped anyway). |
| 187 | + expect(await grantsOf(rig, OBJECT, { positions: [], permissions: [] })).toEqual({}); |
| 188 | + expect(await grantsOf(rig, OBJECT, SYSTEM)).toEqual({}); |
| 189 | + }); |
| 190 | + |
| 191 | + it('grants the override holder every pending record of the object in their organization', async () => { |
| 192 | + const rig = await boot(); |
| 193 | + const a = await open(rig, OBJECT, 'c_a', [{ type: 'user', value: BY_ID }]); |
| 194 | + const b = await open(rig, OBJECT, 'c_b', [{ type: 'user', value: BY_EMAIL_ADDRESS }]); |
| 195 | + const tenantAdmin = { ...member('u_admin'), posture: 'TENANT_ADMIN', permissions: ['organization_admin_no_bypass'] }; |
| 196 | + expect(await grantsOf(rig, OBJECT, tenantAdmin)).toEqual({ c_a: [a], c_b: [b] }); |
| 197 | + }); |
| 198 | + |
| 199 | + it('ends with the step: a decided request grants nothing, on the same request context too', async () => { |
| 200 | + const rig = await boot(); |
| 201 | + const request = await open(rig, OBJECT, 'c_decided', [{ type: 'user', value: BY_ID }]); |
| 202 | + const ctx = member(BY_ID); |
| 203 | + expect(await grantsOf(rig, OBJECT, ctx)).toEqual({ c_decided: [request] }); |
| 204 | + |
| 205 | + await rig.svc.decideNode(request, { decision: 'approve', actorId: BY_ID }, member(BY_ID)); |
| 206 | + const row = await requestRow(rig, { id: request }); |
| 207 | + expect(row.status).toBe('approved'); |
| 208 | + expect(await grantsOf(rig, OBJECT, ctx)).toEqual({}); |
| 209 | + expect(await grantsOf(rig, OBJECT, member(BY_ID))).toEqual({}); |
| 210 | + |
| 211 | + const rejected = await open(rig, OBJECT, 'c_rejected', [{ type: 'user', value: BY_ID }]); |
| 212 | + expect(await grantsOf(rig, OBJECT, ctx)).toEqual({ c_rejected: [rejected] }); |
| 213 | + await rig.svc.decideNode(rejected, { decision: 'reject', actorId: BY_ID }, member(BY_ID)); |
| 214 | + expect(await grantsOf(rig, OBJECT, ctx)).toEqual({}); |
| 215 | + }); |
| 216 | + |
| 217 | + it('never crosses an organization — the platform administrator\'s override included', async () => { |
| 218 | + const rig = await boot(); |
| 219 | + const there = await open(rig, OBJECT, 'c_there', [{ type: 'user', value: BY_ID }], OTHER_ORG); |
| 220 | + const row = await requestRow(rig, { id: there }); |
| 221 | + expect(row.organization_id, 'the request lives in the other organization').toBe(OTHER_ORG); |
| 222 | + |
| 223 | + // The same person, acting in their default organization: nothing. |
| 224 | + expect(await grantsOf(rig, OBJECT, member(BY_ID))).toEqual({}); |
| 225 | + // …and in the request's own organization: the record. |
| 226 | + expect(await grantsOf(rig, OBJECT, member(BY_ID, OTHER_ORG))).toEqual({ c_there: [there] }); |
| 227 | + // The platform administrator may DECIDE across the wall; the read stays home. |
| 228 | + const platformAdmin = { ...member('u_platform'), posture: 'PLATFORM_ADMIN' }; |
| 229 | + expect(await grantsOf(rig, OBJECT, platformAdmin)).toEqual({}); |
| 230 | + // An org-less caller reaches only org-less requests. |
| 231 | + expect(await grantsOf(rig, OBJECT, { userId: BY_ID, positions: [], permissions: [] })).toEqual({}); |
| 232 | + }); |
| 233 | + |
| 234 | + it('costs one account read and one index read per request context, plus one request read per object — never per record', async () => { |
| 235 | + const rig = await boot(); |
| 236 | + const r1 = await open(rig, OBJECT, 'c_1', [{ type: 'user', value: BY_ID }]); |
| 237 | + const r2 = await open(rig, OBJECT, 'c_2', [{ type: 'user', value: BY_ID }]); |
| 238 | + |
| 239 | + const ctx = member(BY_ID); |
| 240 | + rig.finds.length = 0; |
| 241 | + for (let i = 0; i < 5; i++) expect(await grantsOf(rig, OBJECT, ctx)).toEqual({ c_1: [r1], c_2: [r2] }); |
| 242 | + expect(await grantsOf(rig, OTHER, ctx)).toEqual({}); |
| 243 | + expect(rig.finds).toEqual(['sys_user', 'sys_approval_approver', 'sys_approval_request', 'sys_approval_request']); |
| 244 | + |
| 245 | + // A caller on no slate stops after the index read, for every object. |
| 246 | + const stranger = member(STRANGER); |
| 247 | + rig.finds.length = 0; |
| 248 | + await grantsOf(rig, OBJECT, stranger); |
| 249 | + await grantsOf(rig, OTHER, stranger); |
| 250 | + expect(rig.finds).toEqual(['sys_user', 'sys_approval_approver']); |
| 251 | + |
| 252 | + // Any write through the engine retires the memo: the next read asks again. |
| 253 | + await rig.engine.insert(OTHER, { id: 'm_1', title: 'unrelated', organization_id: DEFAULT_ORGANIZATION_ID }, { context: SYSTEM }); |
| 254 | + rig.finds.length = 0; |
| 255 | + await grantsOf(rig, OBJECT, ctx); |
| 256 | + expect(rig.finds).toEqual(['sys_user', 'sys_approval_approver', 'sys_approval_request']); |
| 257 | + }); |
| 258 | + |
| 259 | + it('rethrows a failed read and never memoizes it', async () => { |
| 260 | + const rig = await boot(); |
| 261 | + const request = await open(rig, OBJECT, 'c_fault', [{ type: 'user', value: BY_ID }]); |
| 262 | + const ctx = member(BY_ID); |
| 263 | + const find = (rig.engine as any).find; |
| 264 | + let failNext = true; |
| 265 | + (rig.engine as any).find = (object: string, options?: EngineQueryOptions) => { |
| 266 | + if (object === 'sys_approval_approver' && failNext) { |
| 267 | + failNext = false; |
| 268 | + return Promise.reject(new Error('store unavailable')); |
| 269 | + } |
| 270 | + return find(object, options); |
| 271 | + }; |
| 272 | + await expect(rig.svc.pendingApproverReadGrants(OBJECT, ctx)).rejects.toThrow('store unavailable'); |
| 273 | + expect(await grantsOf(rig, OBJECT, ctx)).toEqual({ c_fault: [request] }); |
| 274 | + }); |
| 275 | +}); |
0 commit comments