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