@@ -102,6 +102,189 @@ describe('per-option visibleWhen — role gating', () => {
102102 } ) ;
103103} ) ;
104104
105+ /**
106+ * #14416 — the fail-open branch must say WHICH of its two cases it took.
107+ *
108+ * A system write (a declarative seed, an in-process job) can never bind
109+ * `current_user`, so a role-gated option logged one `failed to evaluate —
110+ * allowed through` per seeded row — 27 on one ordinary boot, on the correct
111+ * path. An authenticated caller whose predicate genuinely faults produced the
112+ * identical line, and that one is a gate that is not being enforced.
113+ *
114+ * Both branches stay at `warn` (the sink declares only `warn`, and the
115+ * authenticated fault must not get quieter). What the pins hold is that the two
116+ * are told apart, that the fail-open ADMISSION is unchanged in both, and that
117+ * the discriminator needs BOTH facts — no acting user AND a predicate that asks
118+ * for one. A test that only checked the new wording would pass with the branch
119+ * still absent, so every case below asserts `meta.reason` too.
120+ */
121+ describe ( 'per-option visibleWhen — fail-open diagnostics name their case (#14416)' , ( ) => {
122+ /** Collect `(msg, meta)` pairs off the declared `{ warn? }` sink. */
123+ function capture ( ) {
124+ const warns : Array < { msg : string ; meta : any } > = [ ] ;
125+ return { warns, logger : { warn : ( msg : string , meta ?: any ) => warns . push ( { msg, meta } ) } } ;
126+ }
127+
128+ // A predicate that names no user root at all and faults on a typo'd field:
129+ // the case the "no acting user" discriminator MUST NOT swallow.
130+ const typoSchema = {
131+ fields : {
132+ grade : {
133+ type : 'select' ,
134+ options : [ { value : 'gold' , visibleWhen : 'record.typo_field == 1' } ] ,
135+ } ,
136+ } ,
137+ } ;
138+
139+ it ( 'system write + a current_user predicate ⇒ one qualified warn, reason no-acting-user, value admitted' , ( ) => {
140+ const { warns, logger } = capture ( ) ;
141+ expect ( ( ) =>
142+ evaluateValidationRules ( schema , { tier : 'admin_only' } , 'insert' , { logger } ) ,
143+ ) . not . toThrow ( ) ; // fail-open admission unchanged — the seed writes the gated value
144+
145+ expect ( warns ) . toHaveLength ( 1 ) ;
146+ expect ( warns [ 0 ] . msg ) . toBe (
147+ "option visibleWhen for 'tier=admin_only' not evaluated: no acting user to bind current_user (system write) — allowed through" ,
148+ ) ;
149+ // The old line said "failed to evaluate", which is what an operator escalates.
150+ expect ( warns [ 0 ] . msg ) . not . toContain ( 'failed to evaluate' ) ;
151+ expect ( warns [ 0 ] . meta ) . toMatchObject ( {
152+ field : 'tier' ,
153+ value : 'admin_only' ,
154+ reason : 'no-acting-user' ,
155+ } ) ;
156+ // The underlying fault stays recoverable from the line, not just its label.
157+ expect ( warns [ 0 ] . meta . error ) . toMatchObject ( { kind : expect . any ( String ) } ) ;
158+ } ) ;
159+
160+ it ( 'authenticated caller + a genuinely faulting predicate ⇒ the loud warn, reason predicate-fault, value admitted' , ( ) => {
161+ const { warns, logger } = capture ( ) ;
162+ expect ( ( ) =>
163+ evaluateValidationRules ( typoSchema , { grade : 'gold' } , 'insert' , {
164+ currentUser : { id : 'u1' , positions : [ 'admin' ] } ,
165+ logger,
166+ } ) ,
167+ ) . not . toThrow ( ) ; // still fail-open — this card changes the log, not the admission
168+
169+ expect ( warns ) . toHaveLength ( 1 ) ;
170+ expect ( warns [ 0 ] . msg ) . toContain ( "option visibleWhen for 'grade=gold' failed to evaluate" ) ;
171+ expect ( warns [ 0 ] . msg ) . toContain ( '(authenticated caller)' ) ;
172+ expect ( warns [ 0 ] . msg ) . toContain ( 'the option\'s gate was NOT enforced on this write' ) ;
173+ expect ( warns [ 0 ] . meta ) . toMatchObject ( {
174+ field : 'grade' ,
175+ value : 'gold' ,
176+ reason : 'predicate-fault' ,
177+ } ) ;
178+ } ) ;
179+
180+ it ( 'system write + a predicate naming NO user root ⇒ still the LOUD line (the case the user-less test alone would misfile)' , ( ) => {
181+ // This is why the discriminator is not `currentUser === undefined` on its
182+ // own: nothing about this write is expected — the predicate is broken and
183+ // its gate is not being enforced, acting user or not.
184+ const { warns, logger } = capture ( ) ;
185+ expect ( ( ) => evaluateValidationRules ( typoSchema , { grade : 'gold' } , 'insert' , { logger } ) ) . not . toThrow ( ) ;
186+
187+ expect ( warns ) . toHaveLength ( 1 ) ;
188+ expect ( warns [ 0 ] . msg ) . toContain ( 'failed to evaluate' ) ;
189+ expect ( warns [ 0 ] . msg ) . toContain ( '(system write)' ) ;
190+ expect ( warns [ 0 ] . msg ) . toContain ( 'Check the predicate.' ) ;
191+ expect ( warns [ 0 ] . meta ) . toMatchObject ( { reason : 'predicate-fault' } ) ;
192+ } ) ;
193+
194+ it ( 'reads the user root off the AST, not off the fault text (a second fault must not re-loud a seed line)' , ( ) => {
195+ // Measured on this tree: with no acting user,
196+ // `'admin' in current_user.positions` → Unknown variable: current_user
197+ // `'admin' in current_user.positions && record.typo == 1` → No such key: typo
198+ // so a key that matched the message would file the second one as a live
199+ // gate failure on every system write — the noise this branch removes.
200+ const both = {
201+ fields : {
202+ tier : {
203+ type : 'select' ,
204+ options : [
205+ { value : 'admin_only' , visibleWhen : "'admin' in current_user.positions && record.typo == 1" } ,
206+ ] ,
207+ } ,
208+ } ,
209+ } ;
210+ const { warns, logger } = capture ( ) ;
211+ expect ( ( ) => evaluateValidationRules ( both , { tier : 'admin_only' } , 'insert' , { logger } ) ) . not . toThrow ( ) ;
212+
213+ expect ( warns ) . toHaveLength ( 1 ) ;
214+ expect ( warns [ 0 ] . meta ) . toMatchObject ( { reason : 'no-acting-user' } ) ;
215+ expect ( warns [ 0 ] . meta . error . message ) . toContain ( 'No such key: typo' ) ; // the other fault, still reported
216+ } ) ;
217+
218+ it ( 'a user-root ALIAS on a system write is the same case (buildScope mounts one object under four roots)' , ( ) => {
219+ // ADR-0068 D1: `current_user` is canonical, `user` / `ctx.user` / `os.user`
220+ // are aliases for the SAME EvalUser — none of them bind without a user, so
221+ // an alias-spelled gate must not be the loud line on a seed either.
222+ for ( const source of [ 'user.id == record.owner' , 'ctx.user.id == record.owner' , 'os.user.id == record.owner' ] ) {
223+ const aliased = {
224+ fields : { flag : { type : 'select' , options : [ { value : 'on' , visibleWhen : source } ] } } ,
225+ } ;
226+ const { warns, logger } = capture ( ) ;
227+ expect ( ( ) => evaluateValidationRules ( aliased , { flag : 'on' } , 'insert' , { logger } ) ) . not . toThrow ( ) ;
228+ expect ( warns , source ) . toHaveLength ( 1 ) ;
229+ expect ( warns [ 0 ] . meta , source ) . toMatchObject ( { reason : 'no-acting-user' } ) ;
230+ }
231+ } ) ;
232+
233+ describe ( 'regression controls — the accept/reject set does not move' , ( ) => {
234+ it ( 'authenticated caller + predicate FALSE ⇒ still refused with invalid_option' , ( ) => {
235+ const { warns, logger } = capture ( ) ;
236+ let caught : any ;
237+ try {
238+ evaluateValidationRules ( schema , { tier : 'admin_only' } , 'insert' , {
239+ currentUser : { id : 'u1' , positions : [ 'sales' ] } ,
240+ logger,
241+ } ) ;
242+ } catch ( err ) {
243+ caught = err ;
244+ }
245+ expect ( caught ) . toBeInstanceOf ( ValidationError ) ;
246+ expect ( caught . code ) . toBe ( 'VALIDATION_FAILED' ) ;
247+ expect ( caught . fields ) . toEqual ( [
248+ expect . objectContaining ( { field : 'tier' , code : 'invalid_option' } ) ,
249+ ] ) ;
250+ expect ( warns ) . toHaveLength ( 0 ) ; // a clean FALSE is a decision, not a diagnostic
251+ } ) ;
252+
253+ it ( 'authenticated caller + predicate TRUE ⇒ admitted, no warn' , ( ) => {
254+ const { warns, logger } = capture ( ) ;
255+ expect ( ( ) =>
256+ evaluateValidationRules ( schema , { tier : 'admin_only' } , 'insert' , {
257+ currentUser : { id : 'u1' , positions : [ 'admin' ] } ,
258+ logger,
259+ } ) ,
260+ ) . not . toThrow ( ) ;
261+ expect ( warns ) . toHaveLength ( 0 ) ;
262+ } ) ;
263+
264+ it ( 'a cascade predicate that evaluates cleanly on a system write still rejects' , ( ) => {
265+ // No user root, nothing unbound — the gate is enforced on system writes too.
266+ const { warns, logger } = capture ( ) ;
267+ expect ( ( ) =>
268+ evaluateValidationRules ( schema , { country : 'us' , province : 'zj' } , 'insert' , { logger } ) ,
269+ ) . toThrow ( ValidationError ) ;
270+ expect ( warns ) . toHaveLength ( 0 ) ;
271+ } ) ;
272+ } ) ;
273+
274+ it ( 'reproduces the card: N seeded rows log N lines, and none of them says "failed to evaluate"' , ( ) => {
275+ // The card measured 27 identical `failed to evaluate — allowed through`
276+ // lines on one boot, one per seeded row carrying a gated option value.
277+ const N = 27 ;
278+ const { warns, logger } = capture ( ) ;
279+ for ( let i = 0 ; i < N ; i ++ ) {
280+ evaluateValidationRules ( schema , { tier : 'admin_only' } , 'insert' , { logger } ) ;
281+ }
282+ expect ( warns ) . toHaveLength ( N ) ; // the record of each admission is kept (option (c), not (a))
283+ expect ( warns . filter ( ( w ) => w . msg . includes ( 'failed to evaluate' ) ) ) . toHaveLength ( 0 ) ;
284+ expect ( warns . filter ( ( w ) => w . meta ?. reason === 'no-acting-user' ) ) . toHaveLength ( N ) ;
285+ } ) ;
286+ } ) ;
287+
105288describe ( 'per-option visibleWhen — multi-select element-wise' , ( ) => {
106289 const multi = {
107290 fields : {
0 commit comments