Skip to content

Commit d850d24

Browse files
committed
test(plugin-approvals): sys_approval_request's action predicates pass the build pair and the object save door
Runs the object's own action `visible` predicates through `validateStackExpressions`, `runAuthoringRules('build')` (the stack prepared the way `os build` / `os validate` prepare it) and the object save door's `runRuntimeAuthoringRules({ type: 'object' })`: every predicate that reads `record.viewer` passes, a misspelt leaf is refused at all three naming the declared leaves, and with the declaration taken away every reader is refused again. `@objectstack/lint` joins the devDependencies, aliased to source in vitest so the verdict is the current rule's. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8cf030f commit d850d24

4 files changed

Lines changed: 140 additions & 0 deletions

File tree

‎packages/plugins/plugin-approvals/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
},
3030
"devDependencies": {
3131
"@objectstack/driver-sql": "workspace:*",
32+
"@objectstack/lint": "workspace:*",
3233
"@objectstack/metadata-protocol": "workspace:*",
3334
"@objectstack/objectql": "workspace:*",
3435
"@objectstack/service-automation": "workspace:*",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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+
});

‎packages/plugins/plugin-approvals/vitest.config.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ export default defineConfig({
3030
find: /^@objectstack\/metadata-protocol$/,
3131
replacement: path.resolve(__dirname, '../../metadata-protocol/src/index.ts'),
3232
},
33+
// [#22387] `sys-approval-request-attached-on-read.test.ts` runs this
34+
// package's shipped action predicates through the shared validator
35+
// (`validateStackExpressions`, `runAuthoringRules('build')`, the object
36+
// save door), so the verdict must be the CURRENT rule's, not a `lint/dist`
37+
// build's — same #7668/#7778 reason as the two entries above. Anchored:
38+
// `@objectstack/lint` also exports `./runtime` and `./rule-explanations`,
39+
// which a prefix match would resolve through this FILE (`ENOTDIR`).
40+
{
41+
find: /^@objectstack\/lint$/,
42+
replacement: path.resolve(__dirname, '../../lint/src/index.ts'),
43+
},
3344
],
3445
},
3546
test: {

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)