|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#22211 ruling A, #22387] `sys_approval_request`'s own action predicates pass |
| 5 | + * the shared validator, because the object declares the block they read. |
| 6 | + * |
| 7 | + * Eight shipped `visible` predicates gate on `record.viewer.*` — the per-caller |
| 8 | + * block `ApprovalService.attachViewers` sets on every row it serves (#3310). |
| 9 | + * Until the object declared that block under `attachedOnRead`, the validator |
| 10 | + * `os build` / `os validate` run refused all eight as naming an unknown field, |
| 11 | + * and so did the object save door once it judged action predicates. |
| 12 | + * |
| 13 | + * The object is judged here the way those commands judge it: the stack is |
| 14 | + * normalized and parsed (`normalizeStackInput`, `ObjectStackDefinitionSchema`), |
| 15 | + * then read by `validateStackExpressions` and `runAuthoringRules('build')`; |
| 16 | + * the save door is `runRuntimeAuthoringRules({ type: 'object' })`. The rule |
| 17 | + * under test is the expression rule (`expression-invalid`); the object's other |
| 18 | + * build findings are not this file's question. |
| 19 | + * |
| 20 | + * Every assertion reads the object's own predicates and its own declaration — |
| 21 | + * no predicate or leaf list is copied here. Two controls keep the green honest: |
| 22 | + * the same call refuses a misspelt leaf, naming the leaves the object declares, |
| 23 | + * and refuses every `viewer` read once the declaration is taken away. |
| 24 | + */ |
| 25 | + |
| 26 | +import { describe, it, expect } from 'vitest'; |
| 27 | +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; |
| 28 | +import { |
| 29 | + EXPRESSION_INVALID, |
| 30 | + runAuthoringRules, |
| 31 | + runRuntimeAuthoringRules, |
| 32 | + validateStackExpressions, |
| 33 | +} from '@objectstack/lint'; |
| 34 | +import { SysApprovalRequest } from './sys-approval-request.object.js'; |
| 35 | + |
| 36 | +const MANIFEST = { |
| 37 | + id: 'com.objectstack.test.approvals-attached-on-read', |
| 38 | + name: 'approvals_attached_on_read', |
| 39 | + version: '1.0.0', |
| 40 | + type: 'app', |
| 41 | +} as const; |
| 42 | + |
| 43 | +type Action = { name: string; visible?: unknown }; |
| 44 | + |
| 45 | +/** An action predicate's source text — `ObjectSchema.create` stores the CEL envelope. */ |
| 46 | +const sourceOf = (visible: unknown): string => |
| 47 | + typeof visible === 'string' ? visible : String((visible as { source?: unknown } | undefined)?.source ?? ''); |
| 48 | + |
| 49 | +const ACTIONS = ((SysApprovalRequest as { actions?: Action[] }).actions ?? []); |
| 50 | +/** The shipped predicates that read the block: the population this file judges. */ |
| 51 | +const VIEWER_READERS = ACTIONS.filter((a) => sourceOf(a.visible).includes('record.viewer.')); |
| 52 | +const DECLARED_LEAVES = Object.keys(SysApprovalRequest.attachedOnRead?.viewer ?? {}); |
| 53 | + |
| 54 | +const whereOf = (action: string) => `object 'sys_approval_request' · action '${action}' visible`; |
| 55 | + |
| 56 | +/** The object, judged by the build pair and by the object save door. */ |
| 57 | +function judge(object: Record<string, unknown>) { |
| 58 | + const normalized = normalizeStackInput({ manifest: MANIFEST, objects: [object] }) as Record<string, unknown>; |
| 59 | + const result = ObjectStackDefinitionSchema.safeParse(normalized); |
| 60 | + if (!result.success) { |
| 61 | + throw new Error( |
| 62 | + 'fixture is not spec-valid: ' |
| 63 | + + result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '), |
| 64 | + ); |
| 65 | + } |
| 66 | + const parsed = result.data as Record<string, unknown>; |
| 67 | + return { |
| 68 | + expressions: validateStackExpressions(parsed), |
| 69 | + build: runAuthoringRules('build', { normalized, parsed }).filter((f) => f.rule === EXPRESSION_INVALID), |
| 70 | + door: runRuntimeAuthoringRules({ type: 'object', item: object }).errors.filter((f) => f.rule === EXPRESSION_INVALID), |
| 71 | + }; |
| 72 | +} |
| 73 | + |
| 74 | +/** The object with one action's predicate rewritten. */ |
| 75 | +function withPredicate(action: string, rewrite: (source: string) => string): Record<string, unknown> { |
| 76 | + return { |
| 77 | + ...SysApprovalRequest, |
| 78 | + actions: ACTIONS.map((a) => (a.name === action ? { ...a, visible: rewrite(sourceOf(a.visible)) } : a)), |
| 79 | + }; |
| 80 | +} |
| 81 | + |
| 82 | +describe('sys_approval_request — its action predicates against its attachedOnRead declaration', () => { |
| 83 | + it('the population: shipped predicates read the block, and the object declares its leaves', () => { |
| 84 | + expect(VIEWER_READERS.length).toBeGreaterThan(0); |
| 85 | + expect(DECLARED_LEAVES.length).toBeGreaterThan(0); |
| 86 | + }); |
| 87 | + |
| 88 | + it('every predicate passes the build pair and the object save door', () => { |
| 89 | + const { expressions, build, door } = judge(SysApprovalRequest as Record<string, unknown>); |
| 90 | + expect(expressions, JSON.stringify(expressions, null, 2)).toEqual([]); |
| 91 | + expect(build, JSON.stringify(build, null, 2)).toEqual([]); |
| 92 | + expect(door, JSON.stringify(door, null, 2)).toEqual([]); |
| 93 | + }); |
| 94 | + |
| 95 | + it('a misspelt leaf is still refused at all three, naming the leaves the object declares', () => { |
| 96 | + const target = VIEWER_READERS[0].name; |
| 97 | + const leaf = DECLARED_LEAVES[0]; |
| 98 | + const misspelt = `${leaf}_x`; |
| 99 | + const { expressions, build, door } = judge( |
| 100 | + withPredicate(target, (s) => s.replaceAll(`record.viewer.${leaf}`, `record.viewer.${misspelt}`)), |
| 101 | + ); |
| 102 | + for (const [surface, found] of [['validateStackExpressions', expressions], ['build', build], ['door', door]] as const) { |
| 103 | + expect(found, `${surface}: one refusal`).toHaveLength(1); |
| 104 | + const [issue] = found as Array<{ message: string; where?: string; path?: string; severity?: string }>; |
| 105 | + expect(issue.severity ?? 'error', surface).toBe('error'); |
| 106 | + // The subject is the misspelt path, and the remedy names every declared leaf. |
| 107 | + expect(issue.message, surface).toContain(`viewer.${misspelt}`); |
| 108 | + for (const declared of DECLARED_LEAVES) expect(issue.message, `${surface} names \`${declared}\``).toContain(`\`${declared}\``); |
| 109 | + } |
| 110 | + expect(expressions[0].where).toBe(whereOf(target)); |
| 111 | + expect(build[0].where).toBe(whereOf(target)); |
| 112 | + }); |
| 113 | + |
| 114 | + it('control: without the declaration, every predicate that reads the block is refused again', () => { |
| 115 | + const undeclared = Object.fromEntries( |
| 116 | + Object.entries(SysApprovalRequest).filter(([key]) => key !== 'attachedOnRead'), |
| 117 | + ); |
| 118 | + const { expressions, build, door } = judge(undeclared); |
| 119 | + const readers = VIEWER_READERS.map((a) => whereOf(a.name)).sort(); |
| 120 | + expect(expressions.map((i) => i.where).sort()).toEqual(readers); |
| 121 | + expect(build.map((f) => f.where).sort()).toEqual(readers); |
| 122 | + expect(door).toHaveLength(VIEWER_READERS.length); |
| 123 | + for (const issue of [...expressions, ...build, ...door]) expect(issue.message).toContain('`viewer`'); |
| 124 | + }); |
| 125 | +}); |
0 commit comments