Browse Source

Merge branch 'release/4.x'

# Conflicts:
#	backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppLogStore.cs
#	backend/src/Squidex.Domain.Apps.Entities/Apps/IAppLogStore.cs
#	backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppLogStoreTests.cs
#	backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs
pull/568/head
Sebastian 6 years ago
parent
commit
7277aac171
  1. 2
      backend/src/Migrations/RebuilderExtensions.cs
  2. 30
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringJintExtension.cs
  3. 47
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringWordsJintExtension.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  5. 36
      backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/StringFluidExtension.cs
  6. 31
      backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/StringWordsFluidExtension.cs
  7. 137
      backend/src/Squidex.Domain.Apps.Core.Operations/TextHelpers.cs
  8. 37
      backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppLogStore.cs
  9. 3
      backend/src/Squidex.Domain.Apps.Entities/Apps/IAppLogStore.cs
  10. 30
      backend/src/Squidex.Domain.Apps.Entities/Apps/RequestLog.cs
  11. 134
      backend/src/Squidex.Infrastructure/Security/Permission.Part.cs
  12. 54
      backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
  13. 4
      backend/src/Squidex.Web/Pipeline/UsageStream.cs
  14. 9
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  15. 3
      backend/src/Squidex/Program.cs
  16. 4
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs
  17. 3
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs
  18. 71
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs
  19. 3
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
  20. 64
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Templates/FluidTemplateEngineTests.cs
  21. 31
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppLogStoreTests.cs
  22. 11
      backend/tests/Squidex.Infrastructure.Tests/Security/PermissionTests.cs
  23. 67
      backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs
  24. 9
      backend/tools/TestSuite/TestSuite.LoadTests/ReadingBenchmarks.cs
  25. 10
      backend/tools/TestSuite/TestSuite.LoadTests/ReadingContentBenchmarks.cs
  26. 4
      backend/tools/TestSuite/TestSuite.LoadTests/ReadingFixture.cs
  27. 21
      backend/tools/TestSuite/TestSuite.LoadTests/Run.cs
  28. 2
      backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs
  29. 25
      backend/tools/k6/docker-compose.yml
  30. 35
      backend/tools/k6/get-clients.js
  31. 35
      backend/tools/k6/shared.js

2
backend/src/Migrations/RebuilderExtensions.cs

@ -45,7 +45,7 @@ namespace Migrations
public static Task RebuildAssetFoldersAsync(this Rebuilder rebuilder, CancellationToken ct = default)
{
return rebuilder.RebuildAsync<AssetFolderDomainObject, AssetFolderState>("^assetfolder\\-", ct);
return rebuilder.RebuildAsync<AssetFolderDomainObject, AssetFolderState>("^assetFolder\\-", ct);
}
public static Task RebuildContentAsync(this Rebuilder rebuilder, CancellationToken ct = default)

30
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringJintExtension.cs

@ -52,12 +52,42 @@ namespace Squidex.Domain.Apps.Core.Scripting.Extensions
}
};
private readonly Func<string, JsValue> html2Text = text =>
{
try
{
return TextHelpers.Html2Text(text);
}
catch
{
return JsValue.Undefined;
}
};
private readonly Func<string, JsValue> markdown2Text = text =>
{
try
{
return TextHelpers.Markdown2Text(text);
}
catch
{
return JsValue.Undefined;
}
};
public Func<string, JsValue> Html2Text => html2Text;
public void Extend(Engine engine)
{
engine.SetValue("slugify", slugify);
engine.SetValue("toCamelCase", toCamelCase);
engine.SetValue("toPascalCase", toPascalCase);
engine.SetValue("html2Text", Html2Text);
engine.SetValue("markdown2Text", markdown2Text);
}
}
}

47
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringWordsJintExtension.cs

@ -0,0 +1,47 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Jint;
using Jint.Native;
namespace Squidex.Domain.Apps.Core.Scripting.Extensions
{
public sealed class StringWordsJintExtension : IJintExtension
{
private readonly Func<string, JsValue> wordCount = text =>
{
try
{
return TextHelpers.WordCount(text);
}
catch
{
return JsValue.Undefined;
}
};
private readonly Func<string, JsValue> characterCount = text =>
{
try
{
return TextHelpers.CharacterCount(text);
}
catch
{
return JsValue.Undefined;
}
};
public void Extend(Engine engine)
{
engine.SetValue("wordCount", wordCount);
engine.SetValue("characterCount", characterCount);
}
}
}

2
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -17,7 +17,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fluid.Core.Squidex" Version="1.0.0-beta" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.24" />
<PackageReference Include="Jint" Version="3.0.0-beta-1580" />
<PackageReference Include="Markdig" Version="0.20.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="3.1.4" />
<PackageReference Include="Microsoft.OData.Core" Version="7.6.4" />
<PackageReference Include="NJsonSchema" Version="10.1.18" />

36
backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/StringFluidExtension.cs

@ -14,14 +14,7 @@ namespace Squidex.Domain.Apps.Core.Templates.Extensions
{
public sealed class StringFluidExtension : IFluidExtension
{
public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy)
{
TemplateContext.GlobalFilters.AddFilter("escape", Escape);
TemplateContext.GlobalFilters.AddFilter("slugify", Slugify);
TemplateContext.GlobalFilters.AddFilter("trim", Trim);
}
public static FluidValue Slugify(FluidValue input, FilterArguments arguments, TemplateContext context)
private static readonly FilterDelegate Slugify = (input, arguments, context) =>
{
if (input is StringValue value)
{
@ -31,9 +24,9 @@ namespace Squidex.Domain.Apps.Core.Templates.Extensions
}
return input;
}
};
public static FluidValue Escape(FluidValue input, FilterArguments arguments, TemplateContext context)
private static readonly FilterDelegate Escape = (input, arguments, context) =>
{
var result = input.ToStringValue();
@ -41,11 +34,30 @@ namespace Squidex.Domain.Apps.Core.Templates.Extensions
result = result[1..^1];
return FluidValue.Create(result);
}
};
public static FluidValue Trim(FluidValue input, FilterArguments arguments, TemplateContext context)
private static readonly FilterDelegate Markdown2Text = (input, arguments, context) =>
{
return FluidValue.Create(TextHelpers.Markdown2Text(input.ToStringValue()));
};
private static readonly FilterDelegate Html2Text = (input, arguments, context) =>
{
return FluidValue.Create(TextHelpers.Html2Text(input.ToStringValue()));
};
private static readonly FilterDelegate Trim = (input, arguments, context) =>
{
return FluidValue.Create(input.ToStringValue().Trim());
};
public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy)
{
TemplateContext.GlobalFilters.AddFilter("html2text", Html2Text);
TemplateContext.GlobalFilters.AddFilter("markdown2text", Markdown2Text);
TemplateContext.GlobalFilters.AddFilter("escape", Escape);
TemplateContext.GlobalFilters.AddFilter("slugify", Slugify);
TemplateContext.GlobalFilters.AddFilter("trim", Trim);
}
}
}

31
backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/StringWordsFluidExtension.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Fluid;
using Fluid.Values;
namespace Squidex.Domain.Apps.Core.Templates.Extensions
{
public class StringWordsFluidExtension : IFluidExtension
{
private static readonly FilterDelegate WordCount = (input, arguments, context) =>
{
return FluidValue.Create(TextHelpers.WordCount(input.ToStringValue()));
};
private static readonly FilterDelegate CharacterCount = (input, arguments, context) =>
{
return FluidValue.Create(TextHelpers.CharacterCount(input.ToStringValue()));
};
public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy)
{
TemplateContext.GlobalFilters.AddFilter("word_count", WordCount);
TemplateContext.GlobalFilters.AddFilter("character_count", CharacterCount);
}
}
}

137
backend/src/Squidex.Domain.Apps.Core.Operations/TextHelpers.cs

@ -0,0 +1,137 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using HtmlAgilityPack;
using Markdig;
namespace Squidex.Domain.Apps.Core
{
public static class TextHelpers
{
public static string Markdown2Text(string markdown)
{
return Markdown.ToPlainText(markdown).Trim(' ', '\n', '\r');
}
public static string Html2Text(string html)
{
var document = LoadHtml(html);
var sb = new StringBuilder();
WriteTextTo(document.DocumentNode, sb);
return sb.ToString().Trim(' ', '\n', '\r');
}
private static HtmlDocument LoadHtml(string text)
{
var document = new HtmlDocument();
document.LoadHtml(text);
return document;
}
private static void WriteTextTo(HtmlNode node, StringBuilder sb)
{
switch (node.NodeType)
{
case HtmlNodeType.Comment:
break;
case HtmlNodeType.Document:
WriteChildrenTextTo(node, sb);
break;
case HtmlNodeType.Text:
var html = ((HtmlTextNode)node).Text;
if (HtmlNode.IsOverlappedClosingElement(html))
{
break;
}
if (!string.IsNullOrWhiteSpace(html))
{
sb.Append(HtmlEntity.DeEntitize(html));
}
break;
case HtmlNodeType.Element:
switch (node.Name)
{
case "p":
sb.AppendLine();
break;
case "br":
sb.AppendLine();
break;
case "style":
return;
case "script":
return;
}
if (node.HasChildNodes)
{
WriteChildrenTextTo(node, sb);
}
break;
}
}
private static void WriteChildrenTextTo(HtmlNode node, StringBuilder sb)
{
foreach (var child in node.ChildNodes)
{
WriteTextTo(child, sb);
}
}
public static int CharacterCount(string text)
{
var characterCount = 0;
for (int i = 0; i < text.Length; i++)
{
if (char.IsLetter(text[i]))
{
characterCount++;
}
}
return characterCount;
}
public static int WordCount(string text)
{
var numWords = 0;
for (int i = 1; i < text.Length; i++)
{
if (char.IsWhiteSpace(text[i - 1]))
{
var character = text[i];
if (char.IsLetterOrDigit(character) || char.IsPunctuation(character))
{
numWords++;
}
}
}
if (text.Length > 2)
{
numWords++;
}
return numWords;
}
}
}

37
backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppLogStore.cs

@ -14,7 +14,6 @@ using System.Threading;
using System.Threading.Tasks;
using CsvHelper;
using CsvHelper.Configuration;
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log.Store;
@ -24,6 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
private const string FieldAuthClientId = "AuthClientId";
private const string FieldAuthUserId = "AuthUserId";
private const string FieldBytes = "Bytes";
private const string FieldCosts = "Costs";
private const string FieldRequestElapsedMs = "RequestElapsedMs";
private const string FieldRequestMethod = "RequestMethod";
@ -39,26 +39,27 @@ namespace Squidex.Domain.Apps.Entities.Apps
this.requestLogStore = requestLogStore;
}
public Task LogAsync(DomainId appId, Instant timestamp, string? requestMethod, string? requestPath, string? userId, string? clientId, long elapsedMs, double costs)
public Task LogAsync(DomainId appId, RequestLog request)
{
var request = new Request
var storedRequest = new Request
{
Key = appId.ToString(),
Properties = new Dictionary<string, string>
{
[FieldCosts] = costs.ToString(CultureInfo.InvariantCulture)
[FieldCosts] = request.Costs.ToString(CultureInfo.InvariantCulture)
},
Timestamp = timestamp
Timestamp = request.Timestamp
};
Append(request, FieldAuthClientId, clientId);
Append(request, FieldAuthUserId, userId);
Append(request, FieldCosts, costs.ToString(CultureInfo.InvariantCulture));
Append(request, FieldRequestElapsedMs, elapsedMs.ToString(CultureInfo.InvariantCulture));
Append(request, FieldRequestMethod, requestMethod);
Append(request, FieldRequestPath, requestPath);
Append(storedRequest, FieldAuthClientId, request.UserClientId);
Append(storedRequest, FieldAuthUserId, request.UserId);
Append(storedRequest, FieldBytes, request.Bytes);
Append(storedRequest, FieldCosts, request.Costs);
Append(storedRequest, FieldRequestElapsedMs, request.ElapsedMs);
Append(storedRequest, FieldRequestMethod, request.RequestMethod);
Append(storedRequest, FieldRequestPath, request.RequestPath);
return requestLogStore.LogAsync(request);
return requestLogStore.LogAsync(storedRequest);
}
public async Task ReadLogAsync(DomainId appId, DateTime fromDate, DateTime toDate, Stream stream, CancellationToken ct = default)
@ -77,6 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
csv.WriteField(FieldCosts);
csv.WriteField(FieldAuthClientId);
csv.WriteField(FieldAuthUserId);
csv.WriteField(FieldBytes);
await csv.NextRecordAsync();
@ -89,6 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
csv.WriteField(GetDouble(request, FieldCosts));
csv.WriteField(GetString(request, FieldAuthClientId));
csv.WriteField(GetString(request, FieldAuthUserId));
csv.WriteField(GetString(request, FieldBytes));
await csv.NextRecordAsync();
}, appId.ToString(), fromDate, toDate, ct);
@ -108,6 +111,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
}
}
private static void Append(Request request, string key, double value)
{
request.Properties[key] = value.ToString(CultureInfo.InvariantCulture);
}
private static void Append(Request request, string key, long value)
{
request.Properties[key] = value.ToString(CultureInfo.InvariantCulture);
}
private static string GetString(Request request, string key)
{
return request.Properties.GetValueOrDefault(key, string.Empty)!;

3
backend/src/Squidex.Domain.Apps.Entities/Apps/IAppLogStore.cs

@ -9,14 +9,13 @@ using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using NodaTime;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps
{
public interface IAppLogStore
{
Task LogAsync(DomainId appId, Instant timestamp, string? requestMethod, string? requestPath, string? userId, string? clientId, long elapsedMs, double costs);
Task LogAsync(DomainId appId, RequestLog request);
Task ReadLogAsync(DomainId appId, DateTime fromDate, DateTime toDate, Stream stream, CancellationToken ct = default);
}

30
backend/src/Squidex.Domain.Apps.Entities/Apps/RequestLog.cs

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
namespace Squidex.Domain.Apps.Entities.Apps
{
public struct RequestLog
{
public Instant Timestamp;
public string? RequestMethod;
public string? RequestPath;
public string? UserId;
public string? UserClientId;
public long ElapsedMs;
public long Bytes;
public double Costs;
}
}

134
backend/src/Squidex.Infrastructure/Security/Permission.Part.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Linq;
namespace Squidex.Infrastructure.Security
{
@ -14,14 +13,16 @@ namespace Squidex.Infrastructure.Security
{
internal readonly struct Part
{
private static readonly char[] AlternativeSeparators = { '|' };
private static readonly char[] MainSeparators = { '.' };
private const char SeparatorAlternative = '|';
private const char SeparatorMain = '.';
private const char CharAny = '*';
private const char CharExclude = '^';
public readonly string[]? Alternatives;
public readonly ReadOnlyMemory<char>[]? Alternatives;
public readonly bool Exclusion;
public Part(string[]? alternatives, bool exclusion)
public Part(ReadOnlyMemory<char>[]? alternatives, bool exclusion)
{
Alternatives = alternatives;
@ -30,37 +31,118 @@ namespace Squidex.Infrastructure.Security
public static Part[] ParsePath(string path)
{
var parts = path.Split(MainSeparators, StringSplitOptions.RemoveEmptyEntries);
if (string.IsNullOrWhiteSpace(path))
{
return Array.Empty<Part>();
}
var current = path.AsMemory();
var currentSpan = current.Span;
var result = new Part[parts.Length];
var result = new Part[CountOf(currentSpan, SeparatorMain) + 1];
for (var i = 0; i < result.Length; i++)
if (result.Length == 1)
{
result[i] = Parse(parts[i]);
result[0] = Parse(current);
}
else
{
for (int i = 0, j = 0; i < currentSpan.Length; i++)
{
if (currentSpan[i] == SeparatorMain)
{
result[j] = Parse(current.Slice(0, i));
current = current.Slice(i + 1);
currentSpan = current.Span;
i = 0;
j++;
}
else if (i == currentSpan.Length - 1 || currentSpan[i] == SeparatorMain)
{
result[j] = Parse(current);
}
}
}
return result;
}
public static Part Parse(string part)
public static Part Parse(ReadOnlyMemory<char> current)
{
var currentSpan = current.Span;
if (currentSpan.Length == 0)
{
return new Part(Array.Empty<ReadOnlyMemory<char>>(), false);
}
var isExclusion = false;
if (part.StartsWith(Exclude, StringComparison.OrdinalIgnoreCase))
if (currentSpan[0] == CharExclude)
{
isExclusion = true;
part = part.Substring(1);
current = current.Slice(1);
currentSpan = current.Span;
}
if (currentSpan.Length == 0)
{
return new Part(Array.Empty<ReadOnlyMemory<char>>(), isExclusion);
}
if (current.Length > 1 || currentSpan[0] != CharAny)
{
var alternatives = new ReadOnlyMemory<char>[CountOf(currentSpan, SeparatorAlternative) + 1];
if (alternatives.Length == 1)
{
alternatives[0] = current;
}
else
{
for (int i = 0, j = 0; i < current.Length; i++)
{
if (currentSpan[i] == SeparatorAlternative)
{
alternatives[j] = current.Slice(0, i);
current = current.Slice(i + 1);
currentSpan = current.Span;
i = 0;
j++;
}
else if (i == current.Length - 1)
{
alternatives[j] = current;
}
}
}
return new Part(alternatives, isExclusion);
}
else
{
return new Part(null, isExclusion);
}
}
string[]? alternatives = null;
private static int CountOf(ReadOnlySpan<char> text, char character)
{
var count = 0;
if (part != Any)
for (var i = 0; i < text.Length; i++)
{
alternatives = part.Split(AlternativeSeparators, StringSplitOptions.RemoveEmptyEntries);
if (text[i] == character)
{
count++;
}
}
return new Part(alternatives, isExclusion);
return count;
}
public static bool Intersects(ref Part lhs, ref Part rhs, bool allowNull)
@ -70,14 +152,28 @@ namespace Squidex.Infrastructure.Security
return true;
}
if (allowNull && rhs.Alternatives == null)
if (rhs.Alternatives == null)
{
return true;
return allowNull;
}
var shouldIntersect = !(lhs.Exclusion ^ rhs.Exclusion);
return rhs.Alternatives != null && lhs.Alternatives.Intersect(rhs.Alternatives).Any() == shouldIntersect;
var isIntersected = false;
for (var i = 0; i < lhs.Alternatives.Length; i++)
{
for (var j = 0; j < rhs.Alternatives.Length; j++)
{
if (lhs.Alternatives[i].Span.Equals(rhs.Alternatives[j].Span, StringComparison.OrdinalIgnoreCase))
{
isIntersected = true;
break;
}
}
}
return isIntersected == shouldIntersect;
}
}
}

54
backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs

@ -18,17 +18,17 @@ namespace Squidex.Web.Pipeline
{
public sealed class UsageMiddleware : IMiddleware
{
private readonly IAppLogStore log;
private readonly IAppLogStore logStore;
private readonly IApiUsageTracker usageTracker;
private readonly IClock clock;
public UsageMiddleware(IAppLogStore log, IApiUsageTracker usageTracker, IClock clock)
public UsageMiddleware(IAppLogStore logStore, IApiUsageTracker usageTracker, IClock clock)
{
Guard.NotNull(log, nameof(log));
Guard.NotNull(logStore, nameof(logStore));
Guard.NotNull(usageTracker, nameof(usageTracker));
Guard.NotNull(clock, nameof(clock));
this.log = log;
this.logStore = logStore;
this.usageTracker = usageTracker;
@ -51,37 +51,37 @@ namespace Squidex.Web.Pipeline
{
var appId = context.Features.Get<IAppFeature>()?.AppId;
var costs = context.Features.Get<IApiCostsFeature>()?.Costs ?? 0;
if (appId != null)
{
var elapsedMs = watch.Stop();
var now = clock.GetCurrentInstant();
var bytes = usageBody.BytesWritten;
var userId = context.User.OpenIdSubject();
var userClient = context.User.OpenIdClientId();
if (context.Request.ContentLength != null)
{
bytes += context.Request.ContentLength.Value;
}
await log.LogAsync(appId.Id, now,
context.Request.Method,
context.Request.Path,
userId,
userClient,
elapsedMs,
costs);
var request = default(RequestLog);
if (costs > 0)
{
var bytes = usageBody.BytesWritten;
request.Bytes = bytes;
request.Costs = context.Features.Get<IApiCostsFeature>()?.Costs ?? 0;
request.ElapsedMs = watch.Stop();
request.RequestMethod = context.Request.Method;
request.RequestPath = context.Request.Path;
request.Timestamp = clock.GetCurrentInstant();
request.UserClientId = context.User.OpenIdClientId();
request.UserId = context.User.OpenIdSubject();
if (context.Request.ContentLength != null)
{
bytes += context.Request.ContentLength.Value;
}
await logStore.LogAsync(appId.Id, request);
var date = now.ToDateTimeUtc().Date;
if (request.Costs > 0)
{
var date = request.Timestamp.ToDateTimeUtc().Date;
await usageTracker.TrackAsync(date, appId.Id.ToString(), userClient, costs, elapsedMs, bytes);
await usageTracker.TrackAsync(date, appId.Id.ToString(),
request.UserClientId,
request.Costs,
request.ElapsedMs,
request.Bytes);
}
}
}

4
backend/src/Squidex.Web/Pipeline/UsageStream.cs

@ -64,7 +64,7 @@ namespace Squidex.Web.Pipeline
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await base.WriteAsync(buffer, offset, count, cancellationToken);
await inner.WriteAsync(buffer, offset, count, cancellationToken);
bytesWritten += count;
}
@ -78,7 +78,7 @@ namespace Squidex.Web.Pipeline
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
await base.WriteAsync(buffer, cancellationToken);
await inner.WriteAsync(buffer, cancellationToken);
bytesWritten += buffer.Length;
}

9
backend/src/Squidex/Config/Domain/InfrastructureServices.cs

@ -71,6 +71,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<StringJintExtension>()
.As<IJintExtension>();
services.AddSingletonAs<StringWordsJintExtension>()
.As<IJintExtension>();
services.AddSingletonAs<HttpJintExtension>()
.As<IJintExtension>();
@ -83,6 +86,12 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<DateTimeFluidExtension>()
.As<IFluidExtension>();
services.AddSingletonAs<StringFluidExtension>()
.As<IFluidExtension>();
services.AddSingletonAs<StringWordsFluidExtension>()
.As<IFluidExtension>();
services.AddSingletonAs<UserFluidExtension>()
.As<IFluidExtension>();

3
backend/src/Squidex/Program.cs

@ -67,8 +67,7 @@ namespace Squidex
{
if (context.HostingEnvironment.IsDevelopment() || context.Configuration.GetValue<bool>("devMode:enable"))
{
serverOptions.Listen(
IPAddress.Any,
serverOptions.ListenAnyIP(
5001,
listenOptions => listenOptions.UseHttps("../../../dev/squidex-dev.pfx", "password"));
}

4
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs

@ -95,6 +95,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new DateTimeFluidExtension(),
new EventFluidExtensions(urlGenerator),
new StringFluidExtension(),
new StringWordsFluidExtension(),
new UserFluidExtension()
};
@ -107,7 +108,8 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{
new DateTimeJintExtension(),
new EventJintExtension(urlGenerator),
new StringJintExtension()
new StringJintExtension(),
new StringWordsJintExtension()
};
var cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));

3
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs

@ -100,7 +100,8 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{
new DateTimeJintExtension(),
new EventJintExtension(urlGenerator),
new StringJintExtension()
new StringJintExtension(),
new StringWordsJintExtension()
};
var cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));

71
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs

@ -33,7 +33,8 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting
{
new DateTimeJintExtension(),
new HttpJintExtension(httpClientFactory),
new StringJintExtension()
new StringJintExtension(),
new StringWordsJintExtension()
};
var cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
@ -44,6 +45,74 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting
};
}
[Fact]
public void Should_convert_html_to_text()
{
const string script = @"
return html2Text(value);
";
var vars = new ScriptVars
{
["value"] = "<script>Invalid</script><STYLE>Invalid</STYLE><p>Hello World</p>"
};
var result = sut.Execute(vars, script).ToString();
Assert.Equal("Hello World", result);
}
[Fact]
public void Should_convert_markdown_to_text()
{
const string script = @"
return markdown2Text(value);
";
var vars = new ScriptVars
{
["value"] = "## Hello World"
};
var result = sut.Execute(vars, script).ToString();
Assert.Equal("Hello World", result);
}
[Fact]
public void Should_count_words()
{
const string script = @"
return wordCount(value);
";
var vars = new ScriptVars
{
["value"] = "Hello, World"
};
var result = ((JsonNumber)sut.Execute(vars, script)).Value;
Assert.Equal(2, result);
}
[Fact]
public void Should_count_characters()
{
const string script = @"
return characterCount(value);
";
var vars = new ScriptVars
{
["value"] = "Hello, World"
};
var result = ((JsonNumber)sut.Execute(vars, script)).Value;
Assert.Equal(10, result);
}
[Fact]
public void Should_camel_case_value()
{

3
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs

@ -40,7 +40,8 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting
{
new DateTimeJintExtension(),
new HttpJintExtension(httpClientFactory),
new StringJintExtension()
new StringJintExtension(),
new StringWordsJintExtension()
};
var httpResponse = new HttpResponseMessage(HttpStatusCode.OK)

64
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Templates/FluidTemplateEngineTests.cs

@ -24,7 +24,9 @@ namespace Squidex.Domain.Apps.Core.Operations.Templates
{
var extensions = new IFluidExtension[]
{
new DateTimeFluidExtension()
new DateTimeFluidExtension(),
new StringFluidExtension(),
new StringWordsFluidExtension()
};
sut = new FluidTemplateEngine(extensions);
@ -113,6 +115,66 @@ namespace Squidex.Domain.Apps.Core.Operations.Templates
Assert.Equal("Hello", result);
}
[Fact]
public async Task Should_format_html_to_text()
{
var template = "{{ e.text | html2text }}";
var value = new
{
Text = "<script>Invalid</script><STYLE>Invalid</STYLE><p>Hello World</p>"
};
var result = await RenderAync(template, value);
Assert.Equal("Hello World", result);
}
[Fact]
public async Task Should_convert_markdown_to_text()
{
var template = "{{ e.text | markdown2text }}";
var value = new
{
Text = "## Hello World"
};
var result = await RenderAync(template, value);
Assert.Equal("Hello World", result);
}
[Fact]
public async Task Should_format_word_count()
{
var template = "{{ e.text | word_count }}";
var value = new
{
Text = "Hello World"
};
var result = await RenderAync(template, value);
Assert.Equal("2", result);
}
[Fact]
public async Task Should_format_character_count()
{
var template = "{{ e.text | character_count }}";
var value = new
{
text = "Hello World"
};
var result = await RenderAync(template, value);
Assert.Equal("10", result);
}
[Fact]
public async Task Should_throw_exception_when_template_invalid()
{

31
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppLogStoreTests.cs

@ -34,22 +34,27 @@ namespace Squidex.Domain.Apps.Entities.Apps
A.CallTo(() => requestLogStore.LogAsync(A<Request>._))
.Invokes((Request request) => recordedRequest = request);
var clientId = "frontend";
var costs = 2;
var elapsedMs = 120;
var requestMethod = "GET";
var requestPath = "/my-path";
var userId = "user1";
await sut.LogAsync(DomainId.NewGuid(), default, requestMethod, requestPath, userId, clientId, elapsedMs, costs);
var request = default(RequestLog);
request.Bytes = 1024;
request.Costs = 1.5;
request.ElapsedMs = 120;
request.RequestMethod = "GET";
request.RequestPath = "/my-path";
request.Timestamp = default;
request.UserClientId = "frontend";
request.UserId = "user1";
await sut.LogAsync(DomainId.NewGuid(), request);
Assert.NotNull(recordedRequest);
Assert.Contains(clientId, recordedRequest!.Properties.Values);
Assert.Contains(costs.ToString(), recordedRequest!.Properties.Values);
Assert.Contains(elapsedMs.ToString(), recordedRequest!.Properties.Values);
Assert.Contains(requestMethod, recordedRequest!.Properties.Values);
Assert.Contains(requestPath, recordedRequest!.Properties.Values);
Assert.Contains(request.Bytes.ToString(), recordedRequest!.Properties.Values);
Assert.Contains(request.Costs.ToString(), recordedRequest!.Properties.Values);
Assert.Contains(request.ElapsedMs.ToString(), recordedRequest!.Properties.Values);
Assert.Contains(request.RequestMethod, recordedRequest!.Properties.Values);
Assert.Contains(request.RequestPath, recordedRequest!.Properties.Values);
Assert.Contains(request.UserClientId, recordedRequest!.Properties.Values);
Assert.Contains(request.UserId, recordedRequest!.Properties.Values);
}
[Fact]

11
backend/tests/Squidex.Infrastructure.Tests/Security/PermissionTests.cs

@ -166,5 +166,16 @@ namespace Squidex.Infrastructure.Security
Assert.Equal(new List<Permission> { source[2], source[1], source[0] }, sorted);
}
[Theory]
[InlineData("permission")]
[InlineData("permission...")]
[InlineData("permission.||..")]
public void Should_parse_invalid_permissions(string source)
{
var permission = new Permission(source);
permission.Allows(new Permission(Permission.Any));
}
}
}

67
backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.IO;
using System.Text;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.AspNetCore.Http;
@ -14,6 +16,8 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.UsageTracking;
using Xunit;
#pragma warning disable RECS0018 // Comparison of floating point numbers with equality operator
namespace Squidex.Web.Pipeline
{
public class UsageMiddlewareTests
@ -108,14 +112,35 @@ namespace Squidex.Web.Pipeline
}
[Fact]
public async Task Should_track_response_bytes()
public async Task Should_track_response_bytes_with_writer()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
await sut.InvokeAsync(httpContext, async x =>
{
await x.Response.BodyWriter.WriteAsync(Encoding.Default.GetBytes("Hello World"));
await next(x);
});
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, 11))
.MustHaveHappened();
}
[Fact]
public async Task Should_track_response_bytes_with_stream()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
await sut.InvokeAsync(httpContext, async x =>
{
await x.Response.WriteAsync("Hello World");
await x.Response.Body.WriteAsync(Encoding.Default.GetBytes("Hello World"));
await next(x);
});
@ -128,6 +153,37 @@ namespace Squidex.Web.Pipeline
.MustHaveHappened();
}
[Fact]
public async Task Should_track_response_bytes_with_file()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
var tempFileName = Path.GetTempFileName();
try
{
File.WriteAllText(tempFileName, "Hello World");
await sut.InvokeAsync(httpContext, async x =>
{
await x.Response.SendFileAsync(tempFileName, 0, new FileInfo(tempFileName).Length);
await next(x);
});
}
finally
{
File.Delete(tempFileName);
}
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, 11))
.MustHaveHappened();
}
[Fact]
public async Task Should_not_track_if_costs_are_zero()
{
@ -155,7 +211,12 @@ namespace Squidex.Web.Pipeline
await sut.InvokeAsync(httpContext, next);
A.CallTo(() => appLogStore.LogAsync(appId.Id, instant, "GET", "/my-path", null, null, A<long>._, 0))
A.CallTo(() => appLogStore.LogAsync(appId.Id,
A<RequestLog>.That.Matches(x =>
x.Timestamp == instant &&
x.RequestMethod == "GET" &&
x.RequestPath == "/my-path" &&
x.Costs == 0)))
.MustHaveHappened();
}
}

9
backend/tools/TestSuite/TestSuite.LoadTests/ReadingBenchmarks.cs

@ -9,6 +9,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using TestSuite.Fixtures;
using Xunit;
using Xunit.Abstractions;
#pragma warning disable SA1300 // Element should begin with upper-case letter
#pragma warning disable SA1507 // Code should not contain multiple blank lines in a row
@ -17,10 +18,14 @@ namespace TestSuite.LoadTests
{
public class ReadingBenchmarks : IClassFixture<CreatedAppFixture>
{
private readonly ITestOutputHelper testOutput;
public CreatedAppFixture _ { get; }
public ReadingBenchmarks(CreatedAppFixture fixture)
public ReadingBenchmarks(CreatedAppFixture fixture, ITestOutputHelper testOutput)
{
this.testOutput = testOutput;
_ = fixture;
}
@ -65,7 +70,7 @@ namespace TestSuite.LoadTests
await Run.Parallel(numUsers, numIterationsPerUser, async () =>
{
await _.Apps.GetClientsAsync(_.AppName);
});
}, 100, testOutput);
}
}
}

10
backend/tools/TestSuite/TestSuite.LoadTests/ReadingContentBenchmarks.cs

@ -64,7 +64,7 @@ namespace TestSuite.LoadTests
{
await Run.Parallel(numUsers, numIterationsPerUser, async () =>
{
await _.Contents.GetAsync(new ContentQuery { OrderBy = "data/value/iv asc" });
await _.Contents.GetAsync(new ContentQuery { OrderBy = "data/number/iv asc" });
});
}
@ -74,7 +74,7 @@ namespace TestSuite.LoadTests
{
await Run.Parallel(numUsers, numIterationsPerUser, async () =>
{
await _.Contents.GetAsync(new ContentQuery { Skip = 5, OrderBy = "data/value/iv asc" });
await _.Contents.GetAsync(new ContentQuery { Skip = 5, OrderBy = "data/number/iv asc" });
});
}
@ -84,7 +84,7 @@ namespace TestSuite.LoadTests
{
await Run.Parallel(numUsers, numIterationsPerUser, async () =>
{
await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/value/iv asc" });
await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/number/iv asc" });
});
}
@ -94,7 +94,7 @@ namespace TestSuite.LoadTests
{
await Run.Parallel(numUsers, numIterationsPerUser, async () =>
{
await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/value/iv desc" });
await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/number/iv desc" });
});
}
@ -104,7 +104,7 @@ namespace TestSuite.LoadTests
{
await Run.Parallel(numUsers, numIterationsPerUser, async () =>
{
await _.Contents.GetAsync(new ContentQuery { Filter = "data/value/iv gt 3 and data/value/iv lt 7", OrderBy = "data/value/iv asc" });
await _.Contents.GetAsync(new ContentQuery { Filter = "data/number/iv gt 3 and data/number/iv lt 7", OrderBy = "data/number/iv asc" });
});
}
}

4
backend/tools/TestSuite/TestSuite.LoadTests/ReadingFixture.cs

@ -9,10 +9,10 @@ using TestSuite.Fixtures;
namespace TestSuite.LoadTests
{
public sealed class ReadingFixture : ContentFixture
public sealed class ReadingFixture : ContentQueryFixture
{
public ReadingFixture()
: base("benchmark_reading")
: base("benchmark-reading")
{
}
}

21
backend/tools/TestSuite/TestSuite.LoadTests/Run.cs

@ -13,13 +13,16 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using Xunit.Abstractions;
namespace TestSuite.LoadTests
{
public static class Run
{
public static async Task Parallel(int numUsers, int numIterationsPerUser, Func<Task> action, int expectedAvg = 100)
public static async Task Parallel(int numUsers, int numIterationsPerUser, Func<Task> action, int expectedAvg = 100, ITestOutputHelper testOutput = null)
{
await action();
var elapsedMs = new ConcurrentBag<long>();
var errors = 0;
@ -56,16 +59,18 @@ namespace TestSuite.LoadTests
var count = elapsedMs.Count;
var max = elapsedMs.Max();
var min = elapsedMs.Min();
var avg = elapsedMs.Average();
Assert.Equal(0, errors);
Assert.Equal(count, numUsers * numIterationsPerUser);
if (testOutput != null)
{
testOutput.WriteLine("Total Errors: {0}/{1}", errors, numUsers * numIterationsPerUser);
testOutput.WriteLine("Total Count: {0}/{1}", count, numUsers * numIterationsPerUser);
Assert.InRange(max, 0, expectedAvg * 10);
Assert.InRange(min, 0, expectedAvg);
testOutput.WriteLine(string.Empty);
testOutput.WriteLine("Performance Average: {0}", avg);
testOutput.WriteLine("Performance Max: {0}", elapsedMs.Max());
testOutput.WriteLine("Performance Min: {0}", elapsedMs.Min());
}
Assert.InRange(avg, 0, expectedAvg);
}

2
backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs

@ -13,7 +13,7 @@ namespace TestSuite.Fixtures
public class ContentQueryFixture : ContentFixture
{
public ContentQueryFixture()
: this("my-reads")
: this("my-reads")
{
}

25
backend/tools/k6/docker-compose.yml

@ -0,0 +1,25 @@
version: '3.4'
networks:
grafana:
services:
influxdb:
image: influxdb:latest
networks:
- grafana
ports:
- "8086:8086"
environment:
- INFLUXDB_DB=k6
grafana:
image: grafana/grafana:latest
networks:
- grafana
ports:
- "4000:3000"
environment:
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_BASIC_ENABLED=false

35
backend/tools/k6/get-clients.js

@ -0,0 +1,35 @@
import { check } from 'k6';
import http from 'k6/http';
import { variables, getBearerToken } from './shared.js';
export let options = {
stages: [
{ duration: "2m", target: 200 },
{ duration: "2m", target: 200 },
{ duration: "2m", target: 0 },
],
thresholds: {
'http_req_duration': ['p(99)<1500'], // 99% of requests must complete below 1.5s
}
};
export function setup() {
const token = getBearerToken();
return { token };
}
export default function (data) {
const url = `${variables.serverUrl}/api/apps/${variables.appName}/clients`;
const response = http.get(url, {
headers: {
Authorization: `Bearer ${data.token}`
}
});
check(response, {
'is status 200': (r) => r.status === 200,
});
}

35
backend/tools/k6/shared.js

@ -0,0 +1,35 @@
import http from 'k6/http';
export const variables = {
appName: getValue('APP__NAME', 'integration-tests'),
clientId: getValue('CLIENT__ID', 'root'),
clientSecret: getValue('CLIENT__SECRET', 'xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0='),
serverUrl: getValue('SERVER__URL', 'https://localhost:5001')
};
let bearerToken = null;
export function getBearerToken() {
if (!bearerToken) {
const url = `${variables.serverUrl}/identity-server/connect/token`;
const response = http.post(url, {
grant_type: 'client_credentials',
client_id: variables.clientId,
client_secret: variables.clientSecret,
scope: 'squidex-api'
});
const json = JSON.parse(response.body);
bearerToken = json.access_token;
}
return bearerToken;
}
function getValue(key, fallback) {
const result = __ENV[key] || fallback;
return result;
}
Loading…
Cancel
Save