Skip to content

Commit f458ec6

Browse files
committed
wip(spec,lint): judge a controlled_by_parent object's master rule on its merged objectExtensions field set
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
1 parent c857a93 commit f458ec6

4 files changed

Lines changed: 793 additions & 36 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23210 · ADR-0055 §1] `security-controlled-by-parent-ambiguous-relation`
5+
* and `security-controlled-by-parent-no-relation`, judged on the MERGED field
6+
* set of a `controlled_by_parent` object that an `objectExtensions` entry of
7+
* the same stack extends (`extensionMergedCbpFindings`).
8+
*
9+
* The stack schema refuses the same merged shapes
10+
* (`applyControlledByParentExtensionGate`, `@objectstack/spec`); this is the
11+
* door an unparsed intake meets. Every refusal below is pinned by rule id,
12+
* path and message — the message compared with the finding the same object
13+
* gets when it is declared directly — and every silence beside a twin that
14+
* differs in the one variable under test and is reported. The last block
15+
* holds the two doors to one verdict per shape.
16+
*/
17+
import { describe, it, expect } from 'vitest';
18+
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
19+
import {
20+
validateSecurityPosture,
21+
SECURITY_CBP_AMBIGUOUS_RELATION,
22+
SECURITY_CBP_NO_RELATION,
23+
} from './validate-security-posture.js';
24+
25+
const HEADER = { type: 'master_detail', reference: 'cbpx_header', required: true };
26+
const OTHER = { type: 'master_detail', reference: 'cbpx_other', required: true };
27+
28+
const OBJECTS = (lineFields: Record<string, unknown> = { header: HEADER }, sharingModel = 'controlled_by_parent') => [
29+
{ name: 'cbpx_header', sharingModel: 'private', fields: { title: { type: 'text' } } },
30+
{ name: 'cbpx_other', sharingModel: 'private', fields: { title: { type: 'text' } } },
31+
{ name: 'cbpx_line', sharingModel, fields: lineFields },
32+
];
33+
34+
const extending = (fields: Record<string, unknown>, extend = 'cbpx_line') => ({
35+
objects: OBJECTS(),
36+
objectExtensions: [{ extend, fields }],
37+
});
38+
39+
/** The card's stack: one declared master, a second one added by an extension. */
40+
const CARD_STACK = extending({ other: OTHER });
41+
42+
const cbpFindings = (stack: Record<string, unknown>) =>
43+
validateSecurityPosture(stack).filter(
44+
(f) => f.rule === SECURITY_CBP_AMBIGUOUS_RELATION || f.rule === SECURITY_CBP_NO_RELATION,
45+
);
46+
47+
describe('[#23210] validateSecurityPosture judges a controlled_by_parent object on its merged field set', () => {
48+
it('the card\'s stack: ambiguous-relation at the extension\'s fields, in the words of the declared control', () => {
49+
const findings = cbpFindings(CARD_STACK);
50+
expect(findings).toHaveLength(1);
51+
expect(findings[0]).toMatchObject({
52+
severity: 'error',
53+
rule: SECURITY_CBP_AMBIGUOUS_RELATION,
54+
where: 'object "cbpx_line"',
55+
path: 'objectExtensions[0].fields',
56+
});
57+
58+
const control = cbpFindings({ objects: OBJECTS({ header: HEADER, other: OTHER }) });
59+
expect(control.map((f) => [f.rule, f.path])).toEqual([[SECURITY_CBP_AMBIGUOUS_RELATION, 'objects[2].fields']]);
60+
expect(findings[0].message).toBe(control[0].message);
61+
expect(findings[0].hint).toBe(control[0].hint);
62+
expect(findings[0].message).toContain('"header" → cbpx_header, "other" → cbpx_other');
63+
});
64+
65+
it('the single-object control is unchanged, and its declared base alone is clean', () => {
66+
expect(cbpFindings({ objects: OBJECTS({ header: HEADER, other: OTHER }) }).map((f) => f.path)).toEqual([
67+
'objects[2].fields',
68+
]);
69+
expect(cbpFindings({ objects: OBJECTS() })).toEqual([]);
70+
});
71+
72+
it('an extension adding a non-master field is silent; its master-adding twin is reported', () => {
73+
expect(cbpFindings(extending({ other: { type: 'text' } }))).toEqual([]);
74+
expect(cbpFindings(extending({ other: { type: 'lookup', reference: 'cbpx_other', required: true } }))).toEqual([]);
75+
expect(cbpFindings(extending({ other: OTHER })).map((f) => f.path)).toEqual(['objectExtensions[0].fields']);
76+
});
77+
78+
it('an extension that demotes the one master is no-relation at the extension, in the words of the declared control', () => {
79+
for (const demoted of [
80+
{ type: 'master_detail', reference: 'cbpx_header', required: false },
81+
{ type: 'text' },
82+
{ type: 'lookup', reference: 'cbpx_header', required: true },
83+
]) {
84+
const findings = cbpFindings(extending({ header: demoted }));
85+
expect(findings.map((f) => [f.rule, f.path]), JSON.stringify(demoted)).toEqual([
86+
[SECURITY_CBP_NO_RELATION, 'objectExtensions[0].fields'],
87+
]);
88+
const control = cbpFindings({ objects: OBJECTS({ header: demoted }) });
89+
expect(control.map((f) => f.path)).toEqual(['objects[2].sharingModel']);
90+
expect(findings[0].message).toBe(control[0].message);
91+
}
92+
});
93+
94+
it('an extension whose target the stack does not declare is unjudged; the visible twin is reported', () => {
95+
expect(cbpFindings(extending({ a: OTHER, b: HEADER }, 'crm_line'))).toEqual([]);
96+
expect(cbpFindings(extending({ other: OTHER }, 'cbpx__cbpx_line'))).toEqual([]);
97+
expect(cbpFindings(extending({ other: OTHER }, 'cbpx_line'))).toHaveLength(1);
98+
});
99+
100+
it('a target already reported as declared is reported once, at the object', () => {
101+
const stack = {
102+
objects: OBJECTS({ header: HEADER, other: OTHER }),
103+
objectExtensions: [{ extend: 'cbpx_line', fields: { note: { type: 'text' } } }],
104+
};
105+
expect(cbpFindings(stack).map((f) => f.path)).toEqual(['objects[2].fields']);
106+
});
107+
108+
it('reads the array field form of an extension too', () => {
109+
const stack = { objects: OBJECTS(), objectExtensions: [{ extend: 'cbpx_line', fields: [{ name: 'other', ...OTHER }] }] };
110+
expect(cbpFindings(stack).map((f) => f.path)).toEqual(['objectExtensions[0].fields']);
111+
});
112+
113+
it('across the packages of one artifact: an extension in one body judges another body\'s object', () => {
114+
const body = (id: string, extra: Record<string, unknown>) => ({ manifest: { id, name: id, version: '1.0.0', type: 'app', ...extra } });
115+
const stack = (field: unknown) => ({
116+
packages: [
117+
body('com.acme.ext', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: field } }] }),
118+
body('com.acme.core', { objects: OBJECTS() }),
119+
],
120+
});
121+
expect(cbpFindings(stack(OTHER)).map((f) => [f.rule, f.path])).toEqual([
122+
[SECURITY_CBP_AMBIGUOUS_RELATION, 'packages[0].manifest.objectExtensions[0].fields'],
123+
]);
124+
expect(cbpFindings(stack({ type: 'text' }))).toEqual([]);
125+
});
126+
});
127+
128+
// ── The two doors, one verdict per shape ───────────────────────────────────
129+
//
130+
// The stack schema and this rule each read the merge (`@objectstack/lint`
131+
// imports no runtime and the schema's reader is internal to the spec), so the
132+
// table below holds them to the same answer on the same stacks: the path of
133+
// every refusal, schema-side dotted and lint-side bracketed.
134+
describe('[#23210] the parse and the lint agree on the merged shape', () => {
135+
const body = (id: string, extra: Record<string, unknown>) => ({ manifest: { id, name: id, version: '1.0.0', type: 'app', ...extra } });
136+
137+
it.each<[string, Record<string, unknown>, string[]]>([
138+
['the card\'s stack', CARD_STACK, ['objectExtensions.0.fields']],
139+
['a non-master field', extending({ other: { type: 'text' } }), []],
140+
['a demoted master', extending({ header: { type: 'master_detail', reference: 'cbpx_header', required: false } }), ['objectExtensions.0.fields']],
141+
['an unseen target', extending({ other: OTHER }, 'crm_line'), []],
142+
['a non-controlled target', { ...CARD_STACK, objects: OBJECTS({ header: HEADER }, 'private') }, []],
143+
[
144+
'a cross-package extension',
145+
{
146+
packages: [
147+
body('com.acme.ext', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: OTHER } }] }),
148+
body('com.acme.core', { objects: OBJECTS() }),
149+
],
150+
},
151+
['packages.0.manifest.objectExtensions.0.fields'],
152+
],
153+
[
154+
'a later writer restoring one master',
155+
{
156+
packages: [
157+
body('com.acme.adder', { objectExtensions: [{ extend: 'cbpx_line', priority: 200, fields: { other: OTHER } }] }),
158+
body('com.acme.fixer', { objectExtensions: [{ extend: 'cbpx_line', priority: 300, fields: { other: { type: 'text' } } }] }),
159+
body('com.acme.core', { objects: OBJECTS() }),
160+
],
161+
},
162+
[],
163+
],
164+
[
165+
'two packages tied on one key',
166+
{
167+
packages: [
168+
body('com.acme.adder', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: OTHER } }] }),
169+
body('com.acme.fixer', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: { type: 'text' } } }] }),
170+
body('com.acme.core', { objects: OBJECTS() }),
171+
],
172+
},
173+
[],
174+
],
175+
])('%s', (_label, stack, refusedAt) => {
176+
const parsed = ObjectStackDefinitionSchema.safeParse(stack);
177+
const parseIssues = parsed.success
178+
? []
179+
: parsed.error.issues
180+
.filter((i) => /declares sharingModel 'controlled_by_parent'/.test(i.message))
181+
.map((i) => i.path.join('.'));
182+
expect(parseIssues).toEqual(refusedAt);
183+
// An accepted shape parses WHOLE, so a silence is the rule's and not a
184+
// refinement skipped behind some other refusal.
185+
if (refusedAt.length === 0) expect(parsed.success).toBe(true);
186+
187+
const lintPaths = cbpFindings(stack).map((f) => f.path.replace(/\[(\d+)\]/g, '.$1'));
188+
expect(lintPaths).toEqual(refusedAt);
189+
});
190+
});

0 commit comments

Comments
 (0)