@@ -3069,6 +3069,123 @@ describe('runtime authoring gate on OBJECT writes — the object action predicat
30693069 } ) ;
30703070} ) ;
30713071
3072+ /**
3073+ * [#24677] The object save door refuses a `lookup` or `master_detail` whose
3074+ * `reference` is a stored-metadata-body table.
3075+ *
3076+ * The engine never hydrates such a field (`expand` keeps the stored id; a
3077+ * master-detail `parent` root over a family header stays unbound), and this
3078+ * door saved it with a 200, as `os validate` and `os build` built it. The
3079+ * refusal is in `@objectstack/lint`'s expression rule, beside the refusal of a
3080+ * formula reading through such a field; no code here moves. Pinned through
3081+ * the REAL `saveMetaItem` / `publishMetaItem`:
3082+ *
3083+ * (a) each family table, as a `lookup` and as a `master_detail`, is refused
3084+ * with the 422 `INVALID_METADATA` envelope carrying the build's located
3085+ * finding, on an active save AND on a draft's promotion, and nothing
3086+ * lands;
3087+ * (b) the control: a reference to an ordinary platform table still saves;
3088+ * (d) the door's issue and the build's finding are the same finding.
3089+ *
3090+ * The rule's own pins are `@objectstack/lint`'s
3091+ * `validate-expressions.stored-metadata-body-reference.test.ts`.
3092+ *
3093+ * ⚠️ As in the #22019 block above: this package reaches `@objectstack/lint`
3094+ * through its built `dist/`, so an edit to the rule is invisible here until
3095+ * `pnpm --filter @objectstack/lint build` has run.
3096+ */
3097+ describe ( 'runtime authoring gate on OBJECT writes — a relationship into a stored-metadata-body table (#24677)' , ( ) => {
3098+ /** `sharingModel` is authored so `security-owd-unset` stays quiet and the refusal is the reference's. */
3099+ const fxRef = ( type : string , target : string ) => ( {
3100+ name : 'fx_ref' ,
3101+ label : 'Reference Probe' ,
3102+ sharingModel : 'private' ,
3103+ fields : {
3104+ name : { type : 'text' , label : 'Name' } ,
3105+ meta : { type, label : 'Meta' , reference : target } ,
3106+ } ,
3107+ } ) ;
3108+ const FAMILY = [ 'sys_metadata' , 'sys_metadata_history' ] as const ;
3109+ const TYPES = [ 'lookup' , 'master_detail' ] as const ;
3110+ /** Where the build locates the refusal — the KEY the author edits. */
3111+ const WHERE = "object 'fx_ref' · field 'meta' reference" ;
3112+
3113+ const refRows = ( rows : Map < string , Row > ) =>
3114+ Array . from ( rows . values ( ) ) . filter ( ( r ) => r . type === 'object' && r . name === 'fx_ref' ) ;
3115+ const buildFindings = ( obj : unknown ) => {
3116+ const stack = { objects : [ obj ] } ;
3117+ return runAuthoringRules ( 'build' , { normalized : stack , parsed : stack } )
3118+ . filter ( ( f ) => f . rule === EXPRESSION_INVALID && f . where === WHERE ) ;
3119+ } ;
3120+
3121+ for ( const target of FAMILY ) {
3122+ for ( const type of TYPES ) {
3123+ it ( `(a) REFUSES an active save of a \`${ type } \` to '${ target } ' with a 422 carrying the build's located finding` , async ( ) => {
3124+ const { protocol, rows } = makeProtocol ( ) ;
3125+
3126+ const err = await protocol
3127+ . saveMetaItem ( { type : 'object' , name : 'fx_ref' , item : fxRef ( type , target ) } )
3128+ . catch ( ( e : any ) => e ) ;
3129+
3130+ expect ( err , 'the save resolved — the door still accepts the reference' ) . toBeInstanceOf ( Error ) ;
3131+ expect ( { code : err . code , status : err . status } ) . toEqual ( { code : 'INVALID_METADATA' , status : 422 } ) ;
3132+ expect ( err . rulesRun ) . toContain ( 'validateStackExpressions' ) ;
3133+ const issue = err . issues . find ( ( i : any ) => i . rule === EXPRESSION_INVALID && i . where === WHERE ) ;
3134+ expect ( issue , `issues: ${ JSON . stringify ( err . issues ) } ` ) . toBeDefined ( ) ;
3135+ expect ( issue . path ) . toBe ( WHERE ) ;
3136+ expect ( issue . severity ) . toBe ( 'error' ) ;
3137+ // The named subjects: the field, its type and the target.
3138+ expect ( issue . message ) . toContain ( `\`meta\` is a \`${ type } \` whose \`reference\` names '${ target } '` ) ;
3139+ // And nothing landed.
3140+ expect ( refRows ( rows ) ) . toEqual ( [ ] ) ;
3141+ } ) ;
3142+ }
3143+ }
3144+
3145+ it ( "(a) REFUSES the same body on a draft's PROMOTION — the draft door is not a bypass" , async ( ) => {
3146+ const { protocol, rows } = makeProtocol ( ) ;
3147+ await expect (
3148+ protocol . saveMetaItem ( { type : 'object' , name : 'fx_ref' , item : fxRef ( 'master_detail' , 'sys_metadata' ) , mode : 'draft' } ) ,
3149+ ) . resolves . toMatchObject ( { success : true } ) ;
3150+
3151+ const err = await protocol . publishMetaItem ( { type : 'object' , name : 'fx_ref' } ) . catch ( ( e : any ) => e ) ;
3152+
3153+ expect ( { code : err ?. code , status : err ?. status } ) . toEqual ( { code : 'INVALID_METADATA' , status : 422 } ) ;
3154+ const issue = err . issues . find ( ( i : any ) => i . rule === EXPRESSION_INVALID && i . where === WHERE ) ;
3155+ expect ( issue , `issues: ${ JSON . stringify ( err . issues ) } ` ) . toBeDefined ( ) ;
3156+ expect ( refRows ( rows ) . map ( ( r ) => r . state ) ) . not . toContain ( 'active' ) ;
3157+ } ) ;
3158+
3159+ for ( const type of TYPES ) {
3160+ it ( `(b) the control: a \`${ type } \` to an ordinary platform table (\`sys_user\`) still saves, and the row lands` , async ( ) => {
3161+ const { protocol, rows } = makeProtocol ( ) ;
3162+
3163+ const result = await protocol . saveMetaItem ( { type : 'object' , name : 'fx_ref' , item : fxRef ( type , 'sys_user' ) } ) ;
3164+
3165+ expect ( result . success ) . toBe ( true ) ;
3166+ expect ( refRows ( rows ) . map ( ( r ) => r . state ) ) . toEqual ( [ 'active' ] ) ;
3167+ } ) ;
3168+ }
3169+
3170+ it ( '(d) the door and `os build` give the SAME finding for the same field' , async ( ) => {
3171+ const { protocol } = makeProtocol ( ) ;
3172+ const err = await protocol
3173+ . saveMetaItem ( { type : 'object' , name : 'fx_ref' , item : fxRef ( 'lookup' , 'sys_metadata' ) } )
3174+ . catch ( ( e : any ) => e ) ;
3175+ const atDoor = ( err . issues ?? [ ] ) . filter ( ( i : any ) => i . rule === EXPRESSION_INVALID && i . where === WHERE ) ;
3176+
3177+ const atBuild = buildFindings ( fxRef ( 'lookup' , 'sys_metadata' ) ) ;
3178+
3179+ expect ( atBuild ) . toHaveLength ( 1 ) ;
3180+ expect ( atBuild [ 0 ] ! . severity ) . toBe ( 'error' ) ;
3181+ expect ( atDoor ) . toHaveLength ( 1 ) ;
3182+ for ( const k of [ 'rule' , 'where' , 'path' , 'message' , 'hint' ] as const ) {
3183+ expect ( atDoor [ 0 ] [ k ] , `door and build disagree on '${ k } '` ) . toBe ( atBuild [ 0 ] ! [ k ] ) ;
3184+ }
3185+ expect ( buildFindings ( fxRef ( 'lookup' , 'sys_user' ) ) ) . toEqual ( [ ] ) ;
3186+ } ) ;
3187+ } ) ;
3188+
30723189// #23388 — the two page component rules at the runtime save door, ruling of
30733190// record 6161173492 (letter B): `validateComponentProps` and
30743191// `validateComponentTypes` run on a `page` write (`runtimeTypes: ['page']`),
0 commit comments