@@ -294,7 +294,8 @@ for (const [doorName, boot] of DOORS) {
294294 * row. What either door does with them is `RestServer`'s reading, not this
295295 * file's: a token is the body key when it is not nullish (else `If-Match`),
296296 * the key is stripped from the patch whatever its value, and a token is
297- * forwarded as its string only when it is truthy.
297+ * forwarded as its string unless it can never carry a version, which is
298+ * refused (#23492, pinned in its own block below).
298299 */
299300const EDGE_SHAPES : ReadonlyArray < readonly [ string , ( version : string ) => PatchOptions ] > = [
300301 [ 'a stale token' , ( ) => ( { body : { name : AFTER , expectedVersion : STALE } } ) ] ,
@@ -334,6 +335,82 @@ describe('[#23462] the two doors answer each token shape with the same status, c
334335 } , 60_000 ) ;
335336} ) ;
336337
338+ // ── A token that can never carry a version ────────────────────────────────────
339+
340+ /**
341+ * [#23492] Both doors read `bodyVersion ?? If-Match` and used to forward a
342+ * token only when it was truthy. `''` and `false` are not nullish, so the
343+ * stale `If-Match` beside them was never read, and not truthy, so no token was
344+ * forwarded: the update was APPLIED, `200`, on both doors (measured on this
345+ * rig before the fix, the row read back as written). Now a token in a token
346+ * position that can never carry a version is refused with the protocol's own
347+ * answer to the quoted-empty tag `""`, `400 VALIDATION_FAILED`, before
348+ * `updateData` is asked; maintainer decision #20 ① rules that shape refused,
349+ * neither read as "no token" nor answered `409`.
350+ */
351+ const UNUSABLE : ReadonlyArray < readonly [ string , PatchOptions ] > = [
352+ [ "a body expectedVersion of '' beside a stale If-Match" , { body : { name : AFTER , expectedVersion : '' } , headers : { 'If-Match' : STALE } } ] ,
353+ [ "a body expectedVersion of '' and no If-Match" , { body : { name : AFTER , expectedVersion : '' } } ] ,
354+ [ 'a body expectedVersion of false beside a stale If-Match' , { body : { name : AFTER , expectedVersion : false } , headers : { 'If-Match' : STALE } } ] ,
355+ [ 'a body expectedVersion of false and no If-Match' , { body : { name : AFTER , expectedVersion : false } } ] ,
356+ [ 'a blank body expectedVersion beside a stale If-Match' , { body : { name : AFTER , expectedVersion : ' ' } , headers : { 'If-Match' : STALE } } ] ,
357+ [ 'a body expectedVersion of true' , { body : { name : AFTER , expectedVersion : true } } ] ,
358+ [ 'a body expectedVersion that is an array, beside a stale If-Match' , { body : { name : AFTER , expectedVersion : [ ] } , headers : { 'If-Match' : STALE } } ] ,
359+ [ 'an empty If-Match and no body token' , { body : { name : AFTER } , headers : { 'If-Match' : '' } } ] ,
360+ ] ;
361+
362+ const refused = ( a : Answer ) =>
363+ expect ( { status : a . status , code : a . code } , JSON . stringify ( a ) ) . toEqual ( { status : 400 , code : 'VALIDATION_FAILED' } ) ;
364+
365+ for ( const [ doorName , boot ] of DOORS ) {
366+ describe ( `[#23492] ${ doorName } : PATCH /data/:object/:id refuses a token that can never carry a version` , ( ) => {
367+ for ( const [ shape , opts ] of UNUSABLE ) {
368+ it ( `${ shape } : 400 VALIDATION_FAILED, updateData never asked, the row unchanged` , async ( ) => {
369+ const door = await boot ( ) ;
370+ refused ( await door . patch ( opts ) ) ;
371+ expect ( door . forwarded ( ) ) . toEqual ( [ ] ) ;
372+ expect ( await door . name ( ) ) . toBe ( BEFORE ) ;
373+ } , 60_000 ) ;
374+ }
375+
376+ it ( 'the refusal is the protocol\'s own answer to the quoted-empty tag: the same status and code' , async ( ) => {
377+ const quoted = await boot ( ) ;
378+ const protocolAnswer = await quoted . patch ( { body : { name : AFTER , expectedVersion : '""' } } ) ;
379+ // The protocol, not the door, refused this one: the token reached it.
380+ expect ( quoted . forwarded ( ) . map ( ( f ) => f . expectedVersion ) ) . toEqual ( [ '""' ] ) ;
381+ const empty = await boot ( ) ;
382+ const doorAnswer = await empty . patch ( { body : { name : AFTER , expectedVersion : '' } } ) ;
383+ expect ( { status : doorAnswer . status , code : doorAnswer . code } )
384+ . toEqual ( { status : protocolAnswer . status , code : protocolAnswer . code } ) ;
385+ } , 60_000 ) ;
386+
387+ it ( 'control: a null body token is no token, so a stale If-Match beside it is judged and refused 409' , async ( ) => {
388+ const door = await boot ( ) ;
389+ conflict ( await door . patch ( { body : { name : AFTER , expectedVersion : null } , headers : { 'If-Match' : STALE } } ) ) ;
390+ expect ( door . forwarded ( ) . map ( ( f ) => f . expectedVersion ) ) . toEqual ( [ STALE ] ) ;
391+ expect ( await door . name ( ) ) . toBe ( BEFORE ) ;
392+ } , 60_000 ) ;
393+
394+ it ( 'control: a body token of 0 is a number, judged as "0" (refused 409), never dropped' , async ( ) => {
395+ const door = await boot ( ) ;
396+ conflict ( await door . patch ( { body : { name : AFTER , expectedVersion : 0 } , headers : { 'If-Match' : door . version } } ) ) ;
397+ expect ( door . forwarded ( ) ) . toEqual ( [ { expectedVersion : '0' , data : { name : AFTER } } ] ) ;
398+ expect ( await door . name ( ) ) . toBe ( BEFORE ) ;
399+ } , 60_000 ) ;
400+ } ) ;
401+ }
402+
403+ describe ( '[#23492] the two doors refuse each token that can never carry a version alike' , ( ) => {
404+ for ( const [ shape , opts ] of UNUSABLE ) {
405+ it ( `${ shape } : the same status, code and sentence` , async ( ) => {
406+ const answers : Answer [ ] = [ ] ;
407+ for ( const [ , boot ] of DOORS ) answers . push ( await ( await boot ( ) ) . patch ( opts ) ) ;
408+ const [ rest , catchAllAnswer ] = answers ;
409+ expect ( catchAllAnswer ) . toEqual ( rest ) ;
410+ } , 60_000 ) ;
411+ }
412+ } ) ;
413+
337414// ── The dispatcher's fallback updater ─────────────────────────────────────────
338415
339416/**
0 commit comments