diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs index ce162f11c..0095307e8 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs @@ -132,6 +132,29 @@ public sealed class ContentFieldObject : ObjectInstance return valueProperties?.GetValueOrDefault(propertyName) ?? PropertyDescriptor.Undefined; } + protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) + { + // Answers whether a key exists without converting its value, which reading the property would do. + // Used by "in", hasOwnProperty, Object.keys, spread and JSON.stringify. Must give the same answer as + // GetOwnProperty above, which Jint does not check at runtime, only in tests (see + // JintHostContractVerification), so the two methods are kept identical apart from the return value. + EnsurePropertiesInitialized(); + + var propertyName = property.AsString(); + + if (propertyName.Equals("toJSON", StringComparison.OrdinalIgnoreCase)) + { + return OwnPropertyProbe.Missing; + } + + if (!valueProperties.TryGetValue(propertyName, out var propertyDescriptor)) + { + return OwnPropertyProbe.Missing; + } + + return propertyDescriptor.Enumerable ? OwnPropertyProbe.Enumerable : OwnPropertyProbe.NonEnumerable; + } + public override IEnumerable> GetOwnProperties() { EnsurePropertiesInitialized(); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs index 7787448fe..b25920d26 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs @@ -7,6 +7,7 @@ using Jint; using Jint.Native; +using Jint.Runtime.Interop; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Scripting.Internal; @@ -73,7 +74,9 @@ public static class JintExtensions { foreach (var (key, item) in vars) { - engine.SetValue(key, item); + // Sets the value, but runs the conversion only when the script reads it for the first time. + // The name is added right away, so enumeration and "in" checks work as before. + engine.Advanced.AddLazyGlobal(key, e => MapVariable(e, item)); } } @@ -81,4 +84,18 @@ public static class JintExtensions return context; } + + /// + /// Converts a value exactly like does, including its + /// special case for types, so that a deferred variable cannot look different from an eager one. + /// + private static JsValue MapVariable(Engine engine, object? item) + { + if (item is Type type) + { + return TypeReference.CreateTypeReference(engine, type); + } + + return JsValue.FromObject(engine, item); + } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintObjectConverter.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintObjectConverter.cs index 5696a48db..ac0cfad60 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintObjectConverter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintObjectConverter.cs @@ -21,6 +21,29 @@ namespace Squidex.Domain.Apps.Core.Scripting.Internal; public sealed class JintObjectConverter : IObjectConverter { + /// + /// The types this converter handles, passed to Jint when the converter is registered. + /// + /// + /// Without this list Jint has to offer every property of every .NET object to this converter and cannot + /// use its faster property reader for any of them. Base types and interfaces count, so + /// covers all implementations. Keep the list in sync with the switch below - a type + /// that is converted but not listed here fails a test (see JintHostContractVerification). Enums are + /// missing on purpose, they are converted by Jint itself, see EnumConversion in JintScriptEngine. + /// + public static readonly Type[] HandledTypes = + [ + typeof(IUser), + typeof(ClaimsPrincipal), + typeof(ScriptVars), + typeof(JsonValue), + typeof(DomainId), + typeof(Guid), + typeof(Instant), + typeof(Status), + typeof(ContentData), + ]; + public static readonly JintObjectConverter Instance = new JintObjectConverter(); private JintObjectConverter() @@ -31,12 +54,6 @@ public sealed class JintObjectConverter : IObjectConverter { result = null!; - if (value is Enum) - { - result = value.ToString(); - return true; - } - switch (value) { case IUser user: diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs index 4fd3efdf8..00e9e7e56 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Collections; -using System.Globalization; using Jint; using Jint.Native; using Jint.Native.Object; @@ -18,10 +17,6 @@ namespace Squidex.Domain.Apps.Core.Scripting.Internal; public static class JsonMapper { - private sealed class JsonObjectInstance(Engine engine) : ObjectInstance(engine) - { - } - public static JsValue Map(JsonValue value, Engine engine) { switch (value.Value) @@ -33,9 +28,9 @@ public static class JsonMapper case false: return JsBoolean.False; case double n: - return new JsNumber(n); + return JsNumber.Create(n); case string s: - return new JsString(s); + return JsString.Create(s); case JsonObject o: return FromObject(o, engine); case JsonArray a: @@ -58,16 +53,20 @@ public static class JsonMapper return engine.Intrinsics.Array.Construct(target); } - private static JsonObjectInstance FromObject(JsonObject obj, Engine engine) + private static JsObject FromObject(JsonObject obj, Engine engine) { - var target = new JsonObjectInstance(engine); + // Objects that are created this way and have the same keys - all content items of a schema do - + // share one description of their layout, like a class. Reading a property is then a lot faster than + // with a custom ObjectInstance class, where every single object gets its own property dictionary. + var entries = new KeyValuePair[obj.Count]; + var index = 0; foreach (var (key, value) in obj) { - target.Set(key, Map(value, engine)); + entries[index++] = new KeyValuePair(key, Map(value, engine)); } - return target; + return JsObject.CreateFromEntries(engine, entries); } public static JsonValue Map(JsValue? value) @@ -116,11 +115,15 @@ public static class JsonMapper if (value is JsArray a) { - var result = new JsonArray((int)a.Length); + var length = a.Length; + + var result = new JsonArray((int)length); - for (var i = 0; i < a.Length; i++) + // The indexer reads the array storage directly. The old version converted the index to a string + // and did a full property lookup for every element. + for (var i = 0u; i < length; i++) { - result.Add(Map(a.Get(i.ToString(CultureInfo.InvariantCulture)))); + result.Add(Map(a[i])); } return result; diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index 8ad87b9a6..0181c43ad 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -143,10 +143,14 @@ public sealed class JintScriptEngine(IMemoryCache cache, IOptions { - engineOptions.AddObjectConverter(JintObjectConverter.Instance); + engineOptions.AddObjectConverter(JintObjectConverter.Instance, JintObjectConverter.HandledTypes); engineOptions.AllowClrWrite(!options.Readonly); + + // Converts enums to their name, e.g. "Published". This was done by JintObjectConverter before. + engineOptions.Interop.EnumConversion = EnumConversionMode.String; + engineOptions.SetTypeConverter(engine => new CustomClrConverter(engine)); - engineOptions.SetReferencesResolver(NullPropagation.Instance); + engineOptions.SetReferencesResolver(NullPropagation.Instance, NullPropagation.Interests); engineOptions.Strict(); if (!Debugger.IsAttached) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/NullPropagation.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/NullPropagation.cs index ee7684dc6..73cd0a536 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/NullPropagation.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/NullPropagation.cs @@ -14,8 +14,30 @@ namespace Squidex.Domain.Apps.Core.Scripting; public sealed class NullPropagation : IReferenceResolver { + /// + /// The cases this resolver actually handles. + /// + /// + /// Without this list Jint has to assume that we want to see every property read and turns off its read + /// caches for the whole engine. But only ever does something when the + /// value is null or undefined, so the other cases can be left to Jint. Behavior does not change: for a + /// case that is not listed here Jint behaves as if no resolver was registered at all. + /// + public const ReferenceResolverInterests Interests = + ReferenceResolverInterests.NullishPropertyBase | + ReferenceResolverInterests.UnresolvableReference | + ReferenceResolverInterests.NonCallableCallee; + public static readonly NullPropagation Instance = new NullPropagation(); + /// + /// Called when a name does not exist, so that reading an unknown variable does not throw. + /// + /// + /// The returned base is not undefined here but an internal Jint marker string that reads + /// [[Unresolvable]]. That is what scripts have always seen, so it is kept as it is and covered by + /// a test. Returning undefined would be nicer, but would change behavior for existing scripts. + /// public bool TryUnresolvableReference(Engine engine, Reference reference, out JsValue value) { value = reference.Base; diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs index a04b3a255..4c248a78e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) @@ -8,6 +8,7 @@ using Jint; using Jint.Native; using Jint.Native.Object; +using Jint.Runtime.Descriptors; namespace Squidex.Domain.Apps.Core.Scripting; @@ -20,9 +21,15 @@ internal sealed class WritableContext : ObjectInstance { this.vars = vars; + // Adds the value, but runs the conversion only when the script reads it for the first time. Most + // scripts use a few of these variables and some of them are expensive, e.g. the user variable walks + // and groups all claims. The properties themselves are added right away, so key order, enumeration + // and "in" checks stay the same. foreach (var (key, item) in vars) { - base.Set(key, FromObject(engine, item), this); + SetOwnProperty(key, PropertyDescriptor.CreateLazy( + (Engine: engine, Item: item), + static state => FromObject(state.Engine, state.Item))); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index cea511d8c..af696142d 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -20,7 +20,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs index cb0ee139a..a89f82b13 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs @@ -409,6 +409,138 @@ public class ContentDataObjectTests ExecuteScript([], script); } + [Fact] + public void Should_answer_in_operator_for_field_values() + { + const string script = @" + ('iv' in data.string) + ',' + ('unknown' in data.string); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("true,false", actual); + } + + [Fact] + public void Should_answer_has_own_property_for_field_values() + { + const string script = @" + data.string.hasOwnProperty('iv') + ',' + data.string.hasOwnProperty('unknown'); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("true,false", actual); + } + + [Fact] + public void Should_list_field_value_keys() + { + const string script = @" + Object.keys(data.string).join(','); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("iv,de", actual); + } + + [Fact] + public void Should_report_field_values_as_enumerable() + { + const string script = @" + data.string.propertyIsEnumerable('iv') + ',' + data.string.propertyIsEnumerable('unknown'); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("true,false", actual); + } + + [Fact] + public void Should_stringify_field_values() + { + const string script = @" + JSON.stringify(data.string); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("{\"iv\":\"1\",\"de\":\"2\"}", actual); + } + + [Fact] + public void Should_stringify_content_data() + { + const string script = @" + JSON.stringify(data); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("{\"string\":{\"iv\":\"1\",\"de\":\"2\"},\"number\":{\"iv\":42}}", actual); + } + + [Fact] + public void Should_spread_field_values() + { + const string script = @" + JSON.stringify({ ...data.string }); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("{\"iv\":\"1\",\"de\":\"2\"}", actual); + } + + [Fact] + public void Should_not_see_deleted_field_values() + { + const string script = @" + delete data.string.de; + ('de' in data.string) + ',' + Object.keys(data.string).join(','); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("false,iv", actual); + } + + [Fact] + public void Should_see_added_field_values() + { + const string script = @" + data.string.en = '3'; + ('en' in data.string) + ',' + Object.keys(data.string).join(','); + "; + + var actual = EvaluateScript(CreateContent(), script); + + Assert.Equal("true,iv,de,en", actual); + } + + private static ContentData CreateContent() + { + return + new ContentData() + .AddField("string", + new ContentFieldData() + .AddInvariant("1") + .AddLocalized("de", "2")) + .AddField("number", + new ContentFieldData() + .AddInvariant(42)); + } + + private static object? EvaluateScript(ContentData original, string script) + { + var engine = new Engine(o => o.Strict()); + + engine.SetValue("data", new ContentDataObject(engine, original)); + + return engine.Evaluate(script).ToObject(); + } + private static ContentData ExecuteScript(ContentData original, string script) { var engine = new Engine(o => o.Strict()); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs index fcc924cd2..81cc338b6 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs @@ -725,4 +725,497 @@ public class JintScriptEngineTests : IClassFixture Assert.Equal(42.0, result.Value); } + + [Fact] + public void Should_not_throw_if_reading_undeclared_identifier() + { + // Reading an unknown name does not throw, it returns an internal Jint marker string. That is odd, + // but it is what scripts have always seen here, see NullPropagation.TryUnresolvableReference. + const string script = @" + String(unknownName) + '|' + (typeof unknownName); + "; + + var actual = sut.Execute(new ScriptVars(), script); + + Assert.Equal(JsonValue.Create("[[Unresolvable]]|undefined"), actual); + } + + [Fact] + public void Should_null_propagate_over_nullish_property_base() + { + var vars = new ScriptVars + { + ["value"] = 13, + }; + + const string script = @" + ctx.unknown.deeper.evenDeeper === undefined; + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.True, actual); + } + + [Fact] + public void Should_chain_call_over_nullish_property_base() + { + var vars = new ScriptVars + { + ["value"] = 13, + }; + + const string script = @" + ctx.unknown.deeper.someMethod() === undefined; + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.True, actual); + } + + [Fact] + public void Should_return_base_if_calling_non_callable_member() + { + var vars = new ScriptVars + { + ["value"] = "squidex", + }; + + const string script = @" + ctx.value.notAFunction(); + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("squidex"), actual); + } + + [Fact] + public void Should_not_change_normal_member_reads_and_calls() + { + var vars = new ScriptVars + { + ["value"] = JsonValue.Create(new JsonObject().Add("name", JsonValue.Create("squidex"))), + }; + + const string script = @" + ctx.value.name.toUpperCase(); + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("SQUIDEX"), actual); + } + + [Fact] + public void Should_convert_enum_to_name() + { + var vars = new ScriptVars + { + ["value"] = ScriptScope.ContentScript, + }; + + const string script = @" + value; + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create("ContentScript"), actual); + } + + [Fact] + public void Should_convert_flags_enum_to_names() + { + var vars = new ScriptVars + { + ["value"] = ScriptScope.ContentScript | ScriptScope.Transform, + }; + + const string script = @" + value; + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create("ContentScript, Transform"), actual); + } + + [Fact] + public void Should_convert_enum_member_of_wrapped_object_to_name() + { + var vars = new ScriptVars + { + ["value"] = new { scope = ScriptScope.Transform }, + }; + + const string script = @" + value.scope; + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create("Transform"), actual); + } + + [Fact] + public void Should_project_json_object_with_source_key_order() + { + var vars = new ScriptVars + { + ["value"] = CreateJson(), + }; + + const string script = @" + Object.keys(ctx.value).join(','); + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("name,count,nested,items"), actual); + } + + [Fact] + public void Should_stringify_projected_json_object() + { + var vars = new ScriptVars + { + ["value"] = CreateJson(), + }; + + const string script = @" + JSON.stringify(ctx.value); + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal( + JsonValue.Create("{\"name\":\"squidex\",\"count\":3,\"nested\":{\"flag\":true},\"items\":[1,2]}"), + actual); + } + + [Fact] + public void Should_enumerate_projected_json_object() + { + var vars = new ScriptVars + { + ["value"] = CreateJson(), + }; + + const string script = @" + var actual = []; + for (var key in ctx.value) { + actual.push(key + '=' + (typeof ctx.value[key])); + } + actual.join(','); + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal( + JsonValue.Create("name=string,count=number,nested=object,items=object"), + actual); + } + + [Fact] + public void Should_allow_mutation_of_projected_json_object() + { + var vars = new ScriptVars + { + ["value"] = CreateJson(), + }; + + const string script = @" + ctx.value.name = 'changed'; + ctx.value.added = 42; + delete ctx.value.count; + ctx.value; + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + var expected = + JsonValue.Create( + new JsonObject() + .Add("name", JsonValue.Create("changed")) + .Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True))) + .Add("items", JsonValue.Create(new JsonArray().Add(JsonValue.Create(1)).Add(JsonValue.Create(2)))) + .Add("added", JsonValue.Create(42))); + + Assert.Equal(expected, actual); + } + + [Fact] + public void Should_round_trip_projected_json_object() + { + var json = CreateJson(); + + var vars = new ScriptVars + { + ["value"] = json, + }; + + const string script = @" + ctx.value; + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(json, actual); + } + + [Fact] + public void Should_enumerate_context_keys() + { + const string script = @" + Object.keys(ctx).join(','); + "; + + var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("number,text,json,user"), actual); + } + + [Fact] + public void Should_answer_in_operator_for_context_keys() + { + const string script = @" + ('json' in ctx) + ',' + ('unknown' in ctx); + "; + + var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("true,false"), actual); + } + + [Fact] + public void Should_report_types_of_context_values() + { + const string script = @" + var actual = []; + for (var key in ctx) { + actual.push(key + '=' + (typeof ctx[key])); + } + actual.join(','); + "; + + var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("number=number,text=string,json=object,user=object"), actual); + } + + [Fact] + public void Should_read_context_values() + { + const string script = @" + ctx.number + '|' + ctx.text + '|' + ctx.json.name + '|' + ctx.user.id; + "; + + var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("13|hello|squidex|user1"), actual); + } + + [Fact] + public void Should_write_context_value_through_to_vars() + { + var vars = CreateVars(); + + const string script = @" + ctx.number = ctx.number * 2; + "; + + sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(26.0, vars["number"]); + } + + [Fact] + public void Should_delete_context_value() + { + var vars = CreateVars(); + + const string script = @" + delete ctx.text; + Object.keys(ctx).join(','); + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("number,json,user"), actual); + } + + [Fact] + public void Should_not_map_unread_variable() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["number"] = 13, + ["user"] = principal, + }; + + const string script = @" + number + 1; + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create(14), actual); + Assert.Equal(0, principal.Reads); + } + + [Fact] + public void Should_see_unread_variable_in_enumeration() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["user"] = principal, + }; + + const string script = @" + ('user' in globalThis) + ',' + (Object.getOwnPropertyNames(globalThis).indexOf('user') >= 0); + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create("true,true"), actual); + Assert.Equal(0, principal.Reads); + } + + [Fact] + public void Should_map_variable_on_first_read() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["user"] = principal, + }; + + const string script = @" + user.id; + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create("user1"), actual); + Assert.True(principal.Reads > 0); + } + + [Fact] + public void Should_not_map_unread_context_variable() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["number"] = 13, + ["user"] = principal, + }; + + const string script = @" + ctx.number + 1; + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create(14), actual); + Assert.Equal(0, principal.Reads); + } + + [Fact] + public void Should_see_unread_context_variable_in_enumeration() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["number"] = 13, + ["user"] = principal, + }; + + const string script = @" + Object.keys(ctx).join(',') + '|' + ('user' in ctx); + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("number,user|true"), actual); + Assert.Equal(0, principal.Reads); + } + + [Fact] + public void Should_map_context_variable_on_first_read() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["user"] = principal, + }; + + const string script = @" + ctx.user.id; + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("user1"), actual); + Assert.True(principal.Reads > 0); + } + + private sealed class CountingPrincipal : ClaimsPrincipal + { + public int Reads { get; private set; } + + public CountingPrincipal() + : base(new ClaimsIdentity( + [ + new Claim(OpenIdClaims.Subject, "user1"), + new Claim(OpenIdClaims.Name, "user"), + ], "Squidex")) + { + } + + public override IEnumerable Claims + { + get + { + Reads++; + + return base.Claims; + } + } + } + + private static ScriptVars CreateVars() + { + return new ScriptVars + { + ["number"] = 13, + ["text"] = "hello", + ["json"] = CreateJson(), + ["user"] = new ClaimsPrincipal( + new ClaimsIdentity( + [ + new Claim(OpenIdClaims.Subject, "user1"), + new Claim(OpenIdClaims.Name, "user"), + ], "Squidex")), + }; + } + + private static JsonValue CreateJson() + { + return JsonValue.Create( + new JsonObject() + .Add("name", JsonValue.Create("squidex")) + .Add("count", JsonValue.Create(3)) + .Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True))) + .Add("items", JsonValue.Create(new JsonArray().Add(JsonValue.Create(1)).Add(JsonValue.Create(2))))); + } } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs new file mode 100644 index 000000000..40ce7b1b4 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs @@ -0,0 +1,52 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Jint; +using Jint.Native.Object; +using Squidex.Domain.Apps.Core.Scripting.Internal; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Core.Operations.Scripting; + +public class JsonMapperTests +{ + [Fact] + public void Should_map_json_objects_into_a_shared_shape() + { + var engine = new Engine(o => o.Strict()); + + var mapped = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + var nested = (ObjectInstance)mapped.Get("nested"); + + // Sharing the layout is what makes reading properties of many content items fast. It only affects + // performance and never behavior, which is why it is asserted here: building these objects with a + // custom ObjectInstance class again would silently undo it and no other test would notice. + Assert.True(engine.Advanced.HasSharedShape(mapped)); + Assert.True(engine.Advanced.HasSharedShape(nested)); + } + + [Fact] + public void Should_share_the_shape_between_objects_of_the_same_shape() + { + var engine = new Engine(o => o.Strict()); + + var first = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + var second = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + + Assert.True(engine.Advanced.HasSharedShape(first)); + Assert.True(engine.Advanced.HasSharedShape(second)); + } + + private static JsonValue CreateJson() + { + return JsonValue.Create( + new JsonObject() + .Add("name", JsonValue.Create("squidex")) + .Add("count", JsonValue.Create(3)) + .Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True)))); + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/JintHostContractVerification.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/JintHostContractVerification.cs new file mode 100644 index 000000000..6bcce4278 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/JintHostContractVerification.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Runtime.CompilerServices; + +namespace Squidex.Domain.Apps.Core.TestHelpers; + +/// +/// Turns on Jint's self checks for this test assembly. +/// +/// +/// Our ContentWrapper classes and the object converter implement Jint extension points where Jint relies on +/// our answers being consistent, without checking them - checking would cost as much as the shortcut saves. +/// A mistake there is silent in production: a key can disappear from Object.keys, or a converted type can be +/// skipped. With this switch on Jint verifies the answers and throws on the first mismatch, so a mistake +/// fails a test instead. +/// +/// The switch has to be set before the first Jint type is used, which is what the module initializer +/// guarantees. It stays off in production, where the checks would only cost performance. +/// +/// +internal static class JintHostContractVerification +{ + [ModuleInitializer] + internal static void Enable() + { + AppContext.SetSwitch("Jint.EnableHostContractVerification", true); + } +}