Skip to content

Commit 65d699d

Browse files
committed
test(metadata-protocol): the object save door refuses a validations[] predicate that reads parent (422)
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
1 parent 6f9e6bd commit 65d699d

1 file changed

Lines changed: 116 additions & 0 deletions

File tree

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4003,3 +4003,119 @@ describe('runtime authoring gate on VIEW writes that name no package — the who
40034003
expect(JSON.parse(activeViewRows(rows)[0]!.metadata)).toEqual(stored);
40044004
});
40054005
});
4006+
4007+
/**
4008+
* [#24957] The object save door refuses a `validations[]` rule whose predicate
4009+
* reads `parent`, as `os build` does.
4010+
*
4011+
* ObjectQL evaluates every `validations[]` predicate against `record` and
4012+
* `previous` only; the master-detail header is bound as `parent` for a field's
4013+
* own `readonlyWhen` / `requiredWhen` alone. Such a rule faults on every write
4014+
* it judges and fails closed. #24720 reported it at `warning` (the door listed
4015+
* it among its advisories and admitted the save); this card makes it the
4016+
* refusal.
4017+
*
4018+
* The refusal is in `@objectstack/lint` (the validation pass's root verdict);
4019+
* no code here moves. Pinned through the REAL `saveMetaItem`, the header stored
4020+
* in the registry:
4021+
*
4022+
* (a) the measured body is refused on an active save — a 422
4023+
* `INVALID_METADATA` carrying the build's located finding — and nothing
4024+
* lands;
4025+
* (b) the same body still saves as a DRAFT (drafts are never gated), and the
4026+
* draft's promotion is refused;
4027+
* (c) control: `parent` on the field's own `readonlyWhen` saves, and so does a
4028+
* rule reading `record`.
4029+
*
4030+
* ⚠️ As in the blocks above: this package reaches `@objectstack/lint` through
4031+
* its built `dist/`, so an edit to the rule is invisible here until
4032+
* `pnpm --filter @objectstack/lint build` has run.
4033+
*/
4034+
describe('runtime authoring gate on OBJECT writes — a validations[] predicate reading parent (#24957)', () => {
4035+
const header = {
4036+
name: 'fx_header',
4037+
label: 'Header',
4038+
sharingModel: 'private',
4039+
fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
4040+
};
4041+
const line = (validations: unknown[], extraFields: Record<string, unknown> = {}) => ({
4042+
name: 'fx_line',
4043+
label: 'Line',
4044+
sharingModel: 'private',
4045+
fields: {
4046+
hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' },
4047+
amount: { type: 'number', label: 'Amount' },
4048+
...extraFields,
4049+
},
4050+
validations,
4051+
});
4052+
/** The measured body. */
4053+
const PARENT = "parent.status == 'paid'";
4054+
const rule = (condition: string) => ({ name: 'paid_lock', type: 'script', condition, message: 'Locked.' });
4055+
/** Where the build locates it — the rule the author edits. */
4056+
const WHERE = "object 'fx_line' · validation 'paid_lock'";
4057+
4058+
const hostWithHeader = () => {
4059+
const { engine, rows } = makeStubEngine();
4060+
engine.registry.listItems = (type: string) => (type === 'object' ? [header] : []);
4061+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any;
4062+
return { protocol, rows };
4063+
};
4064+
const lineRows = (rows: Map<string, Row>) =>
4065+
Array.from(rows.values()).filter((r) => r.type === 'object' && r.name === 'fx_line');
4066+
const saveLine = (protocol: any, item: unknown, extra: Record<string, unknown> = {}) =>
4067+
protocol.saveMetaItem({ type: 'object', name: 'fx_line', item, ...extra });
4068+
4069+
it('(a) REFUSES an active save with a 422 carrying the build\'s located finding, and nothing lands', async () => {
4070+
const { protocol, rows } = hostWithHeader();
4071+
4072+
const err = await saveLine(protocol, line([rule(PARENT)])).catch((e: any) => e);
4073+
4074+
expect(err, 'the save resolved — the door still admits the rule').toBeInstanceOf(Error);
4075+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
4076+
const issues = err.issues.filter((i: any) => i.rule === EXPRESSION_INVALID);
4077+
expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1);
4078+
expect(issues[0].path).toBe(WHERE);
4079+
expect(issues[0].severity).toBe('error');
4080+
expect(issues[0].message).toContain("validation rule 'paid_lock' reads `parent`");
4081+
expect(lineRows(rows)).toEqual([]);
4082+
4083+
// The door's issue IS the build's finding.
4084+
const stack = { objects: [header, line([rule(PARENT)])] };
4085+
const atBuild = runAuthoringRules('build', { normalized: stack, parsed: stack })
4086+
.filter((f) => f.rule === EXPRESSION_INVALID);
4087+
expect(atBuild).toHaveLength(1);
4088+
for (const key of ['rule', 'where', 'path', 'message', 'severity'] as const) {
4089+
expect(issues[0][key], `door and build disagree on '${key}'`).toBe(atBuild[0]![key]);
4090+
}
4091+
});
4092+
4093+
it('(b) the same body still saves as a DRAFT, and its PROMOTION is refused', async () => {
4094+
const { protocol, rows } = hostWithHeader();
4095+
4096+
await expect(saveLine(protocol, line([rule(PARENT)]), { mode: 'draft' })).resolves.toMatchObject({ success: true });
4097+
expect(lineRows(rows).map((r) => r.state)).toEqual(['draft']);
4098+
4099+
const err = await protocol.publishMetaItem({ type: 'object', name: 'fx_line' }).catch((e: any) => e);
4100+
4101+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
4102+
const issue = err.issues.find((i: any) => i.rule === EXPRESSION_INVALID);
4103+
expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined();
4104+
expect(issue.path).toBe(WHERE);
4105+
expect(lineRows(rows).map((r) => r.state)).toEqual(['draft']);
4106+
});
4107+
4108+
it('(c) control: `parent` on the field\'s own `readonlyWhen`, and a rule reading `record`, save and land', async () => {
4109+
for (const body of [
4110+
line([], { note: { type: 'text', label: 'Note', readonlyWhen: PARENT } }),
4111+
line([rule('record.amount != null && record.amount > 0')]),
4112+
]) {
4113+
const { protocol, rows } = hostWithHeader();
4114+
4115+
const result = await saveLine(protocol, body);
4116+
4117+
expect(result.success, JSON.stringify(body)).toBe(true);
4118+
expect(lineRows(rows).map((r) => r.state)).toEqual(['active']);
4119+
}
4120+
});
4121+
});

0 commit comments

Comments
 (0)