diff --git a/system/MockBox.cfc b/system/MockBox.cfc index 790a6060..3d72d61b 100644 --- a/system/MockBox.cfc +++ b/system/MockBox.cfc @@ -9,6 +9,10 @@ component accessors=true { property name="mockGenerator"; property name="generationPath"; + // Used to gate BoxLang-only type checks (Set/Range) in normalizeValue() so they're + // never attempted on engines where those BIFs don't exist at all (Lucee/Adobe). + variables.IS_BOXLANG = server.keyExists( "boxlang" ); + /** * Create an instance of MockBox * @@ -640,13 +644,9 @@ component accessors=true { // If an object and a class, just use serializeJSON serializedArgs &= serializeJSON( getMetadata( argOrderedTree[ arg ] ) ); } else { - // Get obj rep - try { - serializedArgs &= argOrderedTree[ arg ].toString(); - } catch ( any e ) { - // Fallback - serializedArgs &= serializeJSON( argOrderedTree[ arg ] ); - } + // Deterministic string representation - struct/set key and iteration order + // is not guaranteed to match between two structurally-equal values + serializedArgs &= normalizeValue( argOrderedTree[ arg ] ); } } /* ColdFusion isn't case sensitive, so case of string values shouldn't matter. We do it after serializing all args @@ -655,6 +655,80 @@ component accessors=true { return hash( lCase( serializedArgs ) ); } + /** + * Deterministic string representation of a value for argument hashing. + * Struct keys are sorted so iteration order does not affect the hash. Composite + * values are JSON-encoded (rather than hand-joined with bare delimiters) so that + * delimiter-like characters inside string values can't cause two structurally + * different arguments to normalize to the same string. + * + * @value The value to serialize + */ + private function normalizeValue( required any value ){ + // Simple value + if ( isSimpleValue( arguments.value ) ) { + return toString( arguments.value ); + } + // CFC - must check before struct; CFCs are isStruct+isObject on Adobe + if ( + isObject( arguments.value ) and + ( + isInstanceOf( arguments.value, "Component" ) or structKeyExists( + getMetadata( arguments.value ), + "extends" + ) + ) + ) { + return serializeJSON( getMetadata( arguments.value ) ); + } + // BoxLang Range - must check before isArray(), since isArray() is TRUE for a Range. + // Canonicalize via toString(), which fully captures bounds/step/exclusivity + // ("1..5" vs "1>..<5" vs "1..10.step(3)" all differ) - never iterate/materialize + // the range, since it may be huge or unbounded (an open-start range even throws + // if you try to iterate it). Only ever checked on BoxLang, where isRange() exists. + if ( variables.IS_BOXLANG && isRange( arguments.value ) ) { + return "range:" & arguments.value.toString(); + } + // BoxLang Set - falls through every other check (isStruct/isArray/isObject are + // all false for a Set). Sets are unordered by definition - even same-content + // linked/sorted sets can iterate differently depending on how they were built - + // so normalize each element, then sort the normalized representations, exactly + // like the struct-key sort below, before JSON-encoding. Type-tagged (like Range + // above) so a Set can never collide with an Array holding the same elements. + if ( variables.IS_BOXLANG && isBoxSet( arguments.value ) ) { + var setParts = []; + for ( var item in arguments.value.toArray() ) { + arrayAppend( setParts, isNull( item ) ? "null" : normalizeValue( item ) ); + } + arraySort( setParts, "textnocase" ); + return "set:" & serializeJSON( setParts ); + } + // Struct - sort keys, recurse, then JSON-encode (escapes any delimiter-like + // characters inside values so structurally different args can't collide) + if ( isStruct( arguments.value ) && !isObject( arguments.value ) ) { + var sorted = createObject( "java", "java.util.TreeMap" ).init( arguments.value ); + var canonical = structNew( "ordered" ); + for ( var key in sorted ) { + canonical[ key ] = isNull( sorted[ key ] ) ? "null" : normalizeValue( sorted[ key ] ); + } + return serializeJSON( canonical ); + } + // Array - keep order, recurse, then JSON-encode + if ( isArray( arguments.value ) ) { + var canonical = []; + for ( var item in arguments.value ) { + arrayAppend( canonical, isNull( item ) ? "null" : normalizeValue( item ) ); + } + return serializeJSON( canonical ); + } + // Fallback + try { + return arguments.value.toString(); + } catch ( any e ) { + return serializeJSON( arguments.value ); + } + } + /** * Decorate a mock object with all the necessary methods and properties * diff --git a/tests/specs/mockbox/MockBoxSetRangeTest.bx b/tests/specs/mockbox/MockBoxSetRangeTest.bx new file mode 100644 index 00000000..b42f32d0 --- /dev/null +++ b/tests/specs/mockbox/MockBoxSetRangeTest.bx @@ -0,0 +1,149 @@ +/** + * Tests MockBox's $args() argument matching against BoxLang's native Set and Range + * types (TESTBOX-448). This file is intentionally a .bx file, not a .cfc: BoxLang's + * `..` range operator and `set{}`/setOf()/setNew() constructs are BoxLang-only + * language/BIF surface that Lucee and Adobe don't have, and TestBox's own bundle + * discovery (getSpecPaths() in TestBox.cfc) already skips *.bx bundles entirely when + * not running on BoxLang - so this file is simply never compiled or run on those + * engines, avoiding both a parse-time failure (the `..` operator isn't valid CFML + * syntax at all) and a runtime one (isBoxSet()/isRange() don't exist there). + */ +class extends="testbox.system.BaseSpec" { + + variables.mockBox = ""; + + function run(){ + describe( "MockBox $args() with BoxLang Sets and Ranges", function(){ + beforeEach( function(){ + mockBox = getMockBox(); + } ); + + it( "matches a Set argument regardless of its backing variant or insertion order", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( data = setNew( type = "linked", values = [ 3, 1, 2 ] ) ) + .$results( "matched" ); + + // Same elements, different variant and different insertion/iteration order + var actual = setNew( type = "sorted", values = [ 1, 2, 3 ] ); + + expect( service.save( data = actual ) ).toBe( "matched" ); + } ); + + it( "does not match a Set with different elements", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( data = setOf( 1, 2, 3 ) ) + .$results( "should-not-match" ); + + var result = service.save( data = setOf( 1, 2, 4 ) ); + + expect( isNull( result ) || result != "should-not-match" ).toBeTrue(); + } ); + + it( "does not match an Array holding the same elements as a Set", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( data = setOf( 1, 2, 3 ) ) + .$results( "should-not-match" ); + + // A Set and an Array are different types - a Set match must not leak + // into matching an Array with the same content + var result = service.save( data = [ 1, 2, 3 ] ); + + expect( isNull( result ) || result != "should-not-match" ).toBeTrue(); + } ); + + it( "recursively normalizes and order-independently matches a Set of structs", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( + data = setNew( + type = "linked", + values = [ { a : 1, b : 2 }, { a : 3, b : 4 } ] + ) + ) + .$results( "matched" ); + + // Same struct elements, different struct key order AND different set element order + var actual = setNew( + type = "linked", + values = [ { b : 4, a : 3 }, { b : 2, a : 1 } ] + ); + + expect( service.save( data = actual ) ).toBe( "matched" ); + } ); + + it( "matches a Range argument built with identical bounds", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( data = 1..5 ) + .$results( "matched" ); + + expect( service.save( data = 1..5 ) ).toBe( "matched" ); + } ); + + it( "does not match a Range with different exclusivity", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( data = 1..5 ) + .$results( "should-not-match" ); + + // 1>..<5 excludes both endpoints - a structurally different range + var result = service.save( data = 1>..<5 ); + + expect( isNull( result ) || result != "should-not-match" ).toBeTrue(); + } ); + + it( "does not match a Range with a different step", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( data = 1..10 ) + .$results( "should-not-match" ); + + var result = service.save( data = ( 1..10 ).step( 3 ) ); + + expect( isNull( result ) || result != "should-not-match" ).toBeTrue(); + } ); + + it( "does not confuse a Range with a plain string that looks like one", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( data = 1..5 ) + .$results( "should-not-match" ); + + var result = service.save( data = "1..5" ); + + expect( isNull( result ) || result != "should-not-match" ).toBeTrue(); + } ); + + it( "normalizes an unbounded Range without hanging or throwing", function(){ + var service = mockBox.createStub(); + + service + .$( "save" ) + .$args( data = 1.. ) + .$results( "matched" ); + + expect( service.save( data = 1.. ) ).toBe( "matched" ); + } ); + } ); + } + +} diff --git a/tests/specs/mockbox/MockBoxTest.cfc b/tests/specs/mockbox/MockBoxTest.cfc index bf50e148..2cbc9c65 100755 --- a/tests/specs/mockbox/MockBoxTest.cfc +++ b/tests/specs/mockbox/MockBoxTest.cfc @@ -393,6 +393,131 @@ $assert.isEqual( "UnitTest3", results ); } + // Struct args must match regardless of insertion order (TESTBOX-448) + function testMockArgsStructOrderIndependence(){ + var service = getMockBox().createStub(); + + var expectedArgs = structNew( "ordered" ); + expectedArgs.foo = "one"; + expectedArgs.bar = "two"; + expectedArgs.baz = "three"; + + service + .$( "save" ) + .$args( data = expectedArgs ) + .$results( "matched" ); + + var actualArgs = structNew( "ordered" ); + actualArgs.baz = "three"; + actualArgs.foo = "one"; + actualArgs.bar = "two"; + + $assert.isEqual( "matched", service.save( data = actualArgs ) ); + + // Nested struct: inner key order must not matter either + var expectedNested = structNew( "ordered" ); + expectedNested.outerA = "1"; + expectedNested.inner = structNew( "ordered" ); + expectedNested.inner.a = 1; + expectedNested.inner.b = 2; + expectedNested.outerZ = "9"; + + service + .$( "persist" ) + .$args( payload = expectedNested ) + .$results( "nested-matched" ); + + var actualNested = structNew( "ordered" ); + actualNested.outerZ = "9"; + actualNested.inner = structNew( "ordered" ); + actualNested.inner.b = 2; + actualNested.inner.a = 1; + actualNested.outerA = "1"; + + $assert.isEqual( "nested-matched", service.persist( payload = actualNested ) ); + } + + // Struct args containing a CFC must not trigger Adobe's JSON-serializer cycle + function testMockArgsStructContainingCFC(){ + var service = getMockBox().createStub(); + + var expected = structNew( "ordered" ); + expected.id = 42; + expected.ref = getMockBox().createStub(); + + service + .$( "save" ) + .$args( data = expected ) + .$results( "ok" ); + + var actual = structNew( "ordered" ); + actual.ref = getMockBox().createStub(); + actual.id = 42; + + $assert.isEqual( "ok", service.save( data = actual ) ); + } + + // Deep nesting: struct > array > struct must canonicalise all the way down + function testMockArgsDeepNesting(){ + var service = getMockBox().createStub(); + + var expected = structNew( "ordered" ); + expected.outer = "z"; + expected.items = []; + arrayAppend( expected.items, { a : 1, b : 2 } ); + arrayAppend( expected.items, { a : 3, b : 4 } ); + expected.another = "y"; + + service + .$( "process" ) + .$args( payload = expected ) + .$results( "deep" ); + + var actual = structNew( "ordered" ); + actual.another = "y"; + actual.items = []; + arrayAppend( actual.items, { b : 2, a : 1 } ); + arrayAppend( actual.items, { b : 4, a : 3 } ); + actual.outer = "z"; + + $assert.isEqual( "deep", service.process( payload = actual ) ); + } + + // A string value that happens to contain delimiter-like characters (comma, equals, + // brackets) must not be confused with a structurally different struct/array that + // happens to normalize to the same raw text if delimiters aren't escaped (TESTBOX-448) + function testMockArgsNoDelimiterCollision(){ + var service = getMockBox().createStub(); + + service + .$( "save" ) + .$args( data = { a : 1, b : 2 } ) + .$results( "should-not-match" ); + + // A single key whose value contains a comma and equals sign that, if naively + // joined without escaping, would produce the exact same string as + // { a: 1, b: 2 } serialized as "a=1,b=2" + var result = service.save( data = { a : "1,b=2" } ); + + $assert.isTrue( + isNull( result ) || result != "should-not-match", + "A struct with a comma/equals-containing string value falsely matched a differently-shaped struct - delimiter collision in argument hashing" + ); + + // Same idea for arrays: an embedded comma must not make two different arrays hash the same + var service2 = getMockBox().createStub(); + service2 + .$( "save" ) + .$args( items = [ "1,2", "3" ] ) + .$results( "should-not-match-either" ); + + var result2 = service2.save( items = [ "1", "2,3" ] ); + $assert.isTrue( + isNull( result2 ) || result2 != "should-not-match-either", + "An array with a comma-containing string element falsely matched a differently-shaped array" + ); + } + function testGetProperty(){ mock = getMockBox().createStub(); mock.luis = "Majano";