|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * R2 `relationship/master-detail-required` — two tiers under one rule id. |
| 5 | + * |
| 6 | + * On a `sharingModel: 'controlled_by_parent'` object the master reference is |
| 7 | + * what the object's record access is derived through, and three declarable |
| 8 | + * shapes leave the security gate as the only thing refusing a detail record |
| 9 | + * saved without its master: `required` absent (or `false`), `required: true` + |
| 10 | + * `readonly: true`, and `required: true` + `system: true` (record validation |
| 11 | + * never checks a non-required field and skips readonly/system fields before its |
| 12 | + * required check). All three are refused here at `error` — the v18 narrowing of |
| 13 | + * the authoring contract (maintainer ruling of 2026-08-16, Direction 1). |
| 14 | + * |
| 15 | + * Before the narrowing the predicate was `required !== true` at `warning` on |
| 16 | + * every object, so the two flagged shapes drew no finding at ANY severity. The |
| 17 | + * cases below pin each shape separately for that reason: a one-line severity |
| 18 | + * flip would have turned the first red and left the other two silent. |
| 19 | + * |
| 20 | + * The other half is what keeps this from being a blanket escalation: outside |
| 21 | + * `controlled_by_parent` nothing at runtime refuses a non-required |
| 22 | + * `master_detail`, so there the verdict is unchanged — a `warning` on a missing |
| 23 | + * `required`, and silence on the two flagged shapes. |
| 24 | + */ |
| 25 | + |
| 26 | +import { describe, expect, it } from 'vitest'; |
| 27 | +import { Field, ObjectSchema } from '@objectstack/spec/data'; |
| 28 | +import { lintDataModel } from './data-model-rules.js'; |
| 29 | + |
| 30 | +const R2 = 'relationship/master-detail-required'; |
| 31 | + |
| 32 | +/** The master, plus one detail object carrying exactly the fields under test. */ |
| 33 | +const stack = (sharingModel: string | undefined, fields: unknown): unknown[] => [ |
| 34 | + { name: 'work_order', sharingModel: 'private', fields: { name: { type: 'text' } } }, |
| 35 | + { name: 'work_order_item', ...(sharingModel ? { sharingModel } : {}), fields }, |
| 36 | +]; |
| 37 | + |
| 38 | +const r2 = (objects: unknown[]) => lintDataModel(objects as any[]).filter((issue) => issue.rule === R2); |
| 39 | + |
| 40 | +/** A `master_detail` reference to `work_order`, plus the shape under test. */ |
| 41 | +const masterRef = (shape: Record<string, unknown>) => ({ |
| 42 | + order: { type: 'master_detail', reference: 'work_order', deleteBehavior: 'cascade', ...shape }, |
| 43 | +}); |
| 44 | + |
| 45 | +const UNSAFE_SHAPES: Array<[label: string, shape: Record<string, unknown>, path: string]> = [ |
| 46 | + ['`required` absent', {}, 'objects[1].fields.order.required'], |
| 47 | + ['`required: false`', { required: false }, 'objects[1].fields.order.required'], |
| 48 | + ['`required: true` + `readonly: true`', { required: true, readonly: true }, 'objects[1].fields.order.readonly'], |
| 49 | + ['`required: true` + `system: true`', { required: true, system: true }, 'objects[1].fields.order.system'], |
| 50 | +]; |
| 51 | + |
| 52 | +describe('R2 on a controlled_by_parent object — the three unsafe shapes are refused at error', () => { |
| 53 | + it.each(UNSAFE_SHAPES)('%s → one error, located at the defect', (_label, shape, path) => { |
| 54 | + const issues = r2(stack('controlled_by_parent', masterRef(shape))); |
| 55 | + expect(issues).toHaveLength(1); |
| 56 | + expect(issues[0]).toMatchObject({ severity: 'error', rule: R2, path }); |
| 57 | + }); |
| 58 | + |
| 59 | + // ── CONTROLS — the refusal must be able to stay silent, or the reds above |
| 60 | + // prove nothing about the shapes they name. |
| 61 | + it('CONTROL: `required: true` with neither flag is clean', () => { |
| 62 | + expect(r2(stack('controlled_by_parent', masterRef({ required: true })))).toEqual([]); |
| 63 | + }); |
| 64 | + |
| 65 | + it('CONTROL: an explicit `readonly: false` / `system: false` is clean — the flag, not the key, is the defect', () => { |
| 66 | + expect( |
| 67 | + r2(stack('controlled_by_parent', masterRef({ required: true, readonly: false, system: false }))), |
| 68 | + ).toEqual([]); |
| 69 | + }); |
| 70 | + |
| 71 | + it('a field carrying several defects is ONE finding whose fix names every edit', () => { |
| 72 | + const issues = r2(stack('controlled_by_parent', masterRef({ readonly: true, system: true }))); |
| 73 | + expect(issues).toHaveLength(1); |
| 74 | + expect(issues[0]).toMatchObject({ severity: 'error', path: 'objects[1].fields.order.required' }); |
| 75 | + expect(issues[0].fix).toContain('required: true'); |
| 76 | + expect(issues[0].fix).toContain('readonly'); |
| 77 | + expect(issues[0].fix).toContain('system'); |
| 78 | + }); |
| 79 | + |
| 80 | + // Scope is every `master_detail` of the object, as the builder half of the |
| 81 | + // same ruling enforces it — not only the one the runtime resolves as master. |
| 82 | + it('reports every master_detail field of the object, each on its own path', () => { |
| 83 | + const issues = r2( |
| 84 | + stack('controlled_by_parent', { |
| 85 | + order: { type: 'master_detail', reference: 'work_order', required: true }, |
| 86 | + batch: { type: 'master_detail', reference: 'work_order', required: true, readonly: true }, |
| 87 | + legacy: { type: 'master_detail', reference: 'work_order' }, |
| 88 | + }), |
| 89 | + ); |
| 90 | + expect(issues.map((issue) => [issue.severity, issue.path])).toEqual([ |
| 91 | + ['error', 'objects[1].fields.batch.readonly'], |
| 92 | + ['error', 'objects[1].fields.legacy.required'], |
| 93 | + ]); |
| 94 | + }); |
| 95 | + |
| 96 | + it('reads the array field form too', () => { |
| 97 | + const issues = r2( |
| 98 | + stack('controlled_by_parent', [{ name: 'order', type: 'master_detail', reference: 'work_order', required: true, system: true }]), |
| 99 | + ); |
| 100 | + expect(issues).toHaveLength(1); |
| 101 | + expect(issues[0]).toMatchObject({ severity: 'error', path: 'objects[1].fields.order.system' }); |
| 102 | + }); |
| 103 | + |
| 104 | + it('a lookup is not R2’s subject, whatever the sharing model', () => { |
| 105 | + expect( |
| 106 | + r2(stack('controlled_by_parent', { order: { type: 'lookup', reference: 'work_order', readonly: true } })), |
| 107 | + ).toEqual([]); |
| 108 | + }); |
| 109 | +}); |
| 110 | + |
| 111 | +describe('R2 outside controlled_by_parent — the verdict is unchanged', () => { |
| 112 | + it.each([['private'], ['public_read'], ['public_read_write'], [undefined]])( |
| 113 | + 'sharingModel %s: a missing `required` is still a warning, at `.required`', |
| 114 | + (sharingModel) => { |
| 115 | + const issues = r2(stack(sharingModel, masterRef({}))); |
| 116 | + expect(issues).toHaveLength(1); |
| 117 | + expect(issues[0]).toMatchObject({ |
| 118 | + severity: 'warning', |
| 119 | + path: 'objects[1].fields.order.required', |
| 120 | + fix: 'required: true', |
| 121 | + }); |
| 122 | + }, |
| 123 | + ); |
| 124 | + |
| 125 | + it.each([ |
| 126 | + ['`required: true` + `readonly: true`', { required: true, readonly: true }], |
| 127 | + ['`required: true` + `system: true`', { required: true, system: true }], |
| 128 | + ])('sharingModel private: %s draws nothing, as before', (_label, shape) => { |
| 129 | + expect(r2(stack('private', masterRef(shape)))).toEqual([]); |
| 130 | + }); |
| 131 | +}); |
| 132 | + |
| 133 | +// What actually reaches the rule from the authoring builder. Under |
| 134 | +// `controlled_by_parent`, `ObjectSchema.create()` forces an omitted `required` |
| 135 | +// to `true` and refuses an explicit `false`, but never inspects `readonly` or |
| 136 | +// `system` — so the two flagged shapes leave the builder intact, and this rule |
| 137 | +// is the authoring-time refusal they meet. |
| 138 | +describe('R2 over objects built by ObjectSchema.create()', () => { |
| 139 | + const build = (field: ReturnType<typeof Field.masterDetail>) => |
| 140 | + ObjectSchema.create({ |
| 141 | + name: 'work_order_item', |
| 142 | + sharingModel: 'controlled_by_parent', |
| 143 | + fields: { order: field }, |
| 144 | + }); |
| 145 | + |
| 146 | + it.each([ |
| 147 | + ['readonly', { readonly: true }], |
| 148 | + ['system', { system: true }], |
| 149 | + ])('a %s master reference passes the builder (forced required) and is refused here', (flag, extra) => { |
| 150 | + const detail = build({ ...Field.masterDetail('work_order', { label: 'Work Order' }), ...extra }); |
| 151 | + const issues = r2([{ name: 'work_order', sharingModel: 'private', fields: {} }, detail]); |
| 152 | + expect(issues).toHaveLength(1); |
| 153 | + expect(issues[0]).toMatchObject({ severity: 'error', path: `objects[1].fields.order.${flag}` }); |
| 154 | + }); |
| 155 | + |
| 156 | + it('CONTROL: the builder’s forced `required: true` on a plain master reference lints clean', () => { |
| 157 | + const detail = build(Field.masterDetail('work_order', { label: 'Work Order' })); |
| 158 | + expect(r2([{ name: 'work_order', sharingModel: 'private', fields: {} }, detail])).toEqual([]); |
| 159 | + }); |
| 160 | +}); |
0 commit comments