|
| 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