|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #16679 — the composite pin the card's own sentence names as missing: |
| 4 | +// "neither is visible from inside this repository's own tests — the |
| 5 | +// measurement needed a real app." Each of three gates is individually |
| 6 | +// observable somewhere in this repo; the CONJUNCTION — a served `viewer` |
| 7 | +// block, served `sys_approval_request` action metadata, and a CEL verdict |
| 8 | +// evaluating one against the other, on a real booted app — was pinned |
| 9 | +// nowhere. That is the seam through which a change to any one of the three |
| 10 | +// could silently break the #3424 platform-admin override with every existing |
| 11 | +// test green. |
| 12 | +// |
| 13 | +// ## What this measures, on the wire, on a real boot |
| 14 | +// |
| 15 | +// A plain member (never the platform admin) submits a record into a flow |
| 16 | +// whose sole node routes to a POSITION NOBODY HOLDS — the "stranded" shape: |
| 17 | +// an unresolved approver slate, `lockRecord: true`, otherwise undecidable. |
| 18 | +// The platform admin then reads the SAME request the console's approvals |
| 19 | +// inbox would, on both faces (`listRequests` / `getRequest`), and the SAME |
| 20 | +// `sys_approval_request` action metadata `GET /meta/object` serves — then |
| 21 | +// evaluates the served predicates against the served viewer with |
| 22 | +// `@objectstack/formula`'s `celEngine`, the engine the console itself uses. |
| 23 | +// |
| 24 | +// Both halves are asserted, not just the affirmative one (#16679 ⭐): the four |
| 25 | +// override levers (approve/reject/reassign/recall) must evaluate `visible === |
| 26 | +// true`, AND the four secondary/submitter levers (send_back/request_info/ |
| 27 | +// remind/resubmit) must evaluate `visible === false` for this exact viewer. |
| 28 | +// A pin asserting only the first half would stay green if the predicate |
| 29 | +// degenerated to a constant `true` — precisely the failure a |
| 30 | +// security-adjacent lever must not have. |
| 31 | +// |
| 32 | +// See `test/fixtures/override-composite-fixture.ts` for why this boots a |
| 33 | +// purpose-built object + flow rather than the showcase's own |
| 34 | +// `showcase_budget_approval` (the flow the #16679 measurement round drove): |
| 35 | +// showcase's `onEnable` unconditionally STAFFS the position that flow's |
| 36 | +// second rung needs unstaffed. |
| 37 | + |
| 38 | +import { describe, it, expect } from 'vitest'; |
| 39 | +import { bootStack } from '@objectstack/verify'; |
| 40 | +import { celEngine } from '@objectstack/formula'; |
| 41 | +import type { Expression } from '@objectstack/spec'; |
| 42 | +import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; |
| 43 | +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; |
| 44 | +import { overrideCompositeStack, overrideCompositeSecurity } from './fixtures/override-composite-fixture.js'; |
| 45 | + |
| 46 | +/** The four #3424 override-admitted decision levers — served `visible` must OR in `can_override`. */ |
| 47 | +const OVERRIDE_LEVERS = ['approval_approve', 'approval_reject', 'approval_reassign', 'approval_recall'] as const; |
| 48 | + |
| 49 | +/** |
| 50 | + * The secondary approver levers (gate on `can_act` alone) and the submitter |
| 51 | + * continuity levers (gate on `is_submitter` alone). Neither ORs in |
| 52 | + * `can_override` (deliberate boundary, `action-predicate-sparse-face.test.ts` |
| 53 | + * `:140`) — for THIS viewer (`can_act: false`, `is_submitter: false`) every |
| 54 | + * one of these must evaluate `visible === false`. |
| 55 | + */ |
| 56 | +const NON_OVERRIDE_LEVERS = [ |
| 57 | + 'approval_send_back', |
| 58 | + 'approval_request_info', |
| 59 | + 'approval_remind', |
| 60 | + 'approval_resubmit', |
| 61 | +] as const; |
| 62 | + |
| 63 | +interface ServedViewer { |
| 64 | + can_act: boolean; |
| 65 | + is_submitter: boolean; |
| 66 | + can_override: boolean; |
| 67 | +} |
| 68 | + |
| 69 | +interface ServedAction { |
| 70 | + name: string; |
| 71 | + visible?: Expression; |
| 72 | +} |
| 73 | + |
| 74 | +async function bootFixture() { |
| 75 | + return bootStack(overrideCompositeStack as unknown as Parameters<typeof bootStack>[0], { |
| 76 | + automation: true, |
| 77 | + security: overrideCompositeSecurity(), |
| 78 | + extraPlugins: [new RecordChangeTriggerPlugin(), new ApprovalsServicePlugin()], |
| 79 | + }); |
| 80 | +} |
| 81 | + |
| 82 | +describe('approval override composite (#16679)', () => { |
| 83 | + it( |
| 84 | + 'serves an override-only viewer whose CEL-evaluated actions admit exactly the four override levers', |
| 85 | + async () => { |
| 86 | + const stack = await bootFixture(); |
| 87 | + try { |
| 88 | + // A plain member submits — never the admin — so the admin reading the |
| 89 | + // request back is neither the approver nor the submitter: the exact |
| 90 | + // `{can_act:false, is_submitter:false, can_override:true}` triple the |
| 91 | + // #16679 measurement round read off the wire. |
| 92 | + const memberToken = await stack.signUp('override-composite-member@example.com'); |
| 93 | + const createRes = await stack.apiAs(memberToken, 'POST', '/data/override_composite_request', { |
| 94 | + name: 'Stranded request', |
| 95 | + amount: 100, |
| 96 | + }); |
| 97 | + expect(createRes.status).toBe(201); |
| 98 | + |
| 99 | + const adminToken = await stack.signIn(); |
| 100 | + |
| 101 | + // ── GATE A + the stranded-scene positive control ────────────────── |
| 102 | + // A zero-row list would be indistinguishable from "nothing to measure" |
| 103 | + // (NOT MEASURED, never a pass) — assert the scene actually opened |
| 104 | + // before reading anything off it. |
| 105 | + const listRes = await stack.apiAs(adminToken, 'GET', '/approvals/requests?status=pending'); |
| 106 | + expect(listRes.status).toBe(200); |
| 107 | + const listBody = (await listRes.json()) as { data: Array<Record<string, unknown>> }; |
| 108 | + expect(listBody.data.length).toBe(1); |
| 109 | + const listRow = listBody.data[0]; |
| 110 | + expect(listRow.status).toBe('pending'); |
| 111 | + expect(listRow.pending_approvers).toEqual(['position:override_composite_unstaffed']); |
| 112 | + expect(listRow.lock_record).toBe(true); |
| 113 | + expect(listRow.viewer).toBeTruthy(); |
| 114 | + |
| 115 | + const requestId = String(listRow.id); |
| 116 | + const getRes = await stack.apiAs(adminToken, 'GET', `/approvals/requests/${requestId}`); |
| 117 | + expect(getRes.status).toBe(200); |
| 118 | + const getRow = (await getRes.json()) as Record<string, unknown>; |
| 119 | + expect(getRow.viewer).toBeTruthy(); |
| 120 | + |
| 121 | + // ── GATE B — `can_override` true on BOTH faces, and the fail-safe's |
| 122 | + // other two flags correctly false for this actor ──────────────── |
| 123 | + const expectedViewer: ServedViewer = { can_act: false, is_submitter: false, can_override: true }; |
| 124 | + expect(listRow.viewer).toEqual(expectedViewer); |
| 125 | + expect(getRow.viewer).toEqual(expectedViewer); |
| 126 | + |
| 127 | + // ── GATE C — the served action metadata ORs `can_override` in on |
| 128 | + // exactly the four core levers, and nowhere else ───────────────── |
| 129 | + const metaRes = await stack.apiAs(adminToken, 'GET', '/meta/object/sys_approval_request'); |
| 130 | + expect(metaRes.status).toBe(200); |
| 131 | + const metaBody = (await metaRes.json()) as { item?: { actions?: ServedAction[] } }; |
| 132 | + const actions = metaBody.item?.actions ?? []; |
| 133 | + expect(actions.length).toBe(8); |
| 134 | + |
| 135 | + const byName = new Map(actions.map((a) => [a.name, a] as const)); |
| 136 | + for (const name of [...OVERRIDE_LEVERS, ...NON_OVERRIDE_LEVERS]) { |
| 137 | + const action = byName.get(name); |
| 138 | + expect(action, `served actions must include '${name}'`).toBeTruthy(); |
| 139 | + expect(action?.visible?.dialect).toBe('cel'); |
| 140 | + } |
| 141 | + |
| 142 | + // ── COMPOSITE — evaluate the SERVED predicates against the SERVED |
| 143 | + // viewer with @objectstack/formula's celEngine, the engine the |
| 144 | + // console itself uses (`action-predicate-sparse-face.test.ts` |
| 145 | + // treats it as the authority). `record` is the served row itself |
| 146 | + // — the same binding shape the console evaluates `visible` |
| 147 | + // against (`record.status`, `record.viewer.*`). ──────────────── |
| 148 | + const evaluateVisible = (action: ServedAction | undefined): boolean => { |
| 149 | + if (!action?.visible) throw new Error('action carries no visible predicate'); |
| 150 | + const result = celEngine.evaluate(action.visible, { record: getRow }); |
| 151 | + if (!result.ok) { |
| 152 | + throw new Error(`CEL evaluation of '${action.name}' faulted: ${JSON.stringify(result)}`); |
| 153 | + } |
| 154 | + return result.value === true; |
| 155 | + }; |
| 156 | + |
| 157 | + // ⭐ Both halves. A pin asserting only this first loop would stay |
| 158 | + // green if `visible` degenerated to a constant `true`. |
| 159 | + for (const name of OVERRIDE_LEVERS) { |
| 160 | + expect(evaluateVisible(byName.get(name)), `${name} must be visible=true for an override-only viewer`).toBe( |
| 161 | + true, |
| 162 | + ); |
| 163 | + } |
| 164 | + for (const name of NON_OVERRIDE_LEVERS) { |
| 165 | + expect( |
| 166 | + evaluateVisible(byName.get(name)), |
| 167 | + `${name} must stay visible=false — it must not admit an actor who is neither the approver nor the submitter`, |
| 168 | + ).toBe(false); |
| 169 | + } |
| 170 | + } finally { |
| 171 | + await stack.stop(); |
| 172 | + } |
| 173 | + }, |
| 174 | + 60_000, |
| 175 | + ); |
| 176 | +}); |
0 commit comments