Browse Source

Usage notifications. (#493)

pull/494/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
a7edbc2282
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 22
      backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs
  2. 13
      backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs
  3. 113
      backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailSender.cs
  4. 19
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IUsageNotifierGrain.cs
  5. 98
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs
  6. 24
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotification.cs
  7. 90
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs
  8. 8
      backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs
  9. 11
      backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs
  10. 178
      backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs
  11. 6
      backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailTextOptions.cs
  12. 1
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  13. 43
      backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs
  14. 4
      backend/src/Squidex.Infrastructure/Email/SmtpOptions.cs
  15. 21
      backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  16. 13
      backend/src/Squidex/Config/Domain/NotificationsServices.cs
  17. 3
      backend/src/Squidex/Config/Domain/SubscriptionServices.cs
  18. 12
      backend/src/Squidex/appsettings.json
  19. 21
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEventConsumerTests.cs
  20. 134
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs
  21. 84
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/NotificationEmailSenderTests.cs
  22. 33
      backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs
  23. 58
      backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs

22
backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs

@ -5,8 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Net;
using System.Net.Mail;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules;
@ -43,6 +45,8 @@ namespace Squidex.Extensions.Actions.Email
protected override async Task<Result> ExecuteJobAsync(EmailJob job, CancellationToken ct = default)
{
await CheckConnectionAsync(job, ct);
using (var client = new SmtpClient(job.ServerHost, job.ServerPort)
{
Credentials = new NetworkCredential(
@ -64,6 +68,24 @@ namespace Squidex.Extensions.Actions.Email
return Result.Complete();
}
private async Task CheckConnectionAsync(EmailJob job, CancellationToken ct)
{
using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
{
var tcs = new TaskCompletionSource<IAsyncResult>();
var state = socket.BeginConnect(job.ServerHost, job.ServerPort, tcs.SetResult, null);
using (ct.Register(() =>
{
tcs.TrySetException(new OperationCanceledException($"Failed to establish a connection to {job.ServerHost}:{job.ServerPort}"));
}))
{
await tcs.Task;
}
}
}
}
public sealed class EmailJob

13
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailEventConsumer.cs → backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs

@ -7,18 +7,19 @@
using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps.Notifications;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class NotificationEmailEventConsumer : IEventConsumer
public sealed class InvitationEventConsumer : IEventConsumer
{
private static readonly Duration MaxAge = Duration.FromDays(2);
private readonly INotificationEmailSender emailSender;
private readonly INotificationSender emailSender;
private readonly IUserResolver userResolver;
private readonly ISemanticLog log;
@ -32,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
get { return "^app-"; }
}
public NotificationEmailEventConsumer(INotificationEmailSender emailSender, IUserResolver userResolver, ISemanticLog log)
public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver, ISemanticLog log)
{
Guard.NotNull(emailSender);
Guard.NotNull(userResolver);
@ -103,9 +104,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
var appName = appContributorAssigned.AppId.Name;
var isCreated = appContributorAssigned.IsCreated;
await emailSender.SendContributorEmailAsync(assigner, assignee, appName, isCreated);
await emailSender.SendInviteAsync(assigner, assignee, appName);
}
}

113
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailSender.cs

@ -1,113 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Email;
using Squidex.Infrastructure.Log;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
{
public sealed class NotificationEmailSender : INotificationEmailSender
{
private readonly IEmailSender emailSender;
private readonly IEmailUrlGenerator emailUrlGenerator;
private readonly ISemanticLog log;
private readonly NotificationEmailTextOptions texts;
public bool IsActive
{
get { return true; }
}
public NotificationEmailSender(
IOptions<NotificationEmailTextOptions> texts,
IEmailSender emailSender,
IEmailUrlGenerator emailUrlGenerator,
ISemanticLog log)
{
Guard.NotNull(texts);
Guard.NotNull(emailSender);
Guard.NotNull(emailUrlGenerator);
Guard.NotNull(log);
this.texts = texts.Value;
this.emailSender = emailSender;
this.emailUrlGenerator = emailUrlGenerator;
this.log = log;
}
public Task SendContributorEmailAsync(IUser assigner, IUser assignee, string appName, bool isCreated)
{
Guard.NotNull(assigner);
Guard.NotNull(assignee);
Guard.NotNull(appName);
if (assignee.HasConsent())
{
return SendEmailAsync(texts.ExistingUserSubject, texts.ExistingUserBody, assigner, assignee, appName);
}
else
{
return SendEmailAsync(texts.NewUserSubject, texts.NewUserBody, assigner, assignee, appName);
}
}
private async Task SendEmailAsync(string emailSubj, string emailBody, IUser assigner, IUser assignee, string appName)
{
if (string.IsNullOrWhiteSpace(emailBody))
{
LogWarning("No email subject configured for new users");
return;
}
if (string.IsNullOrWhiteSpace(emailSubj))
{
LogWarning("No email body configured for new users");
return;
}
var appUrl = emailUrlGenerator.GenerateUIUrl();
emailSubj = Format(emailSubj, assigner, assignee, appUrl, appName);
emailBody = Format(emailBody, assigner, assignee, appUrl, appName);
await emailSender.SendAsync(assignee.Email, emailSubj, emailBody);
}
private void LogWarning(string reason)
{
log.LogWarning(w => w
.WriteProperty("action", "InviteUser")
.WriteProperty("status", "Failed")
.WriteProperty("reason", reason));
}
private static string Format(string text, IUser assigner, IUser assignee, string uiUrl, string appName)
{
text = text.Replace("$APP_NAME", appName);
if (assigner != null)
{
text = text.Replace("$ASSIGNER_EMAIL", assigner.Email);
text = text.Replace("$ASSIGNER_NAME", assigner.DisplayName());
}
if (assignee != null)
{
text = text.Replace("$ASSIGNEE_EMAIL", assignee.Email);
text = text.Replace("$ASSIGNEE_NAME", assignee.DisplayName());
}
text = text.Replace("$UI_URL", uiUrl);
return text;
}
}
}

19
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IUsageNotifierGrain.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public interface IUsageNotifierGrain : IGrainWithStringKey
{
[OneWay]
Task NotifyAsync(UsageNotification notification);
}
}

98
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs

@ -0,0 +1,98 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Orleans;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public class UsageGate
{
private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IAppPlansProvider appPlansProvider;
private readonly IApiUsageTracker apiUsageTracker;
private readonly IUsageNotifierGrain usageLimitNotifier;
public UsageGate(IAppPlansProvider appPlansProvider, IApiUsageTracker apiUsageTracker, IGrainFactory grainFactory)
{
Guard.NotNull(apiUsageTracker);
Guard.NotNull(appPlansProvider);
Guard.NotNull(grainFactory);
this.appPlansProvider = appPlansProvider;
this.apiUsageTracker = apiUsageTracker;
usageLimitNotifier = grainFactory.GetGrain<IUsageNotifierGrain>(SingleGrain.Id);
}
public virtual async Task<bool> IsBlockedAsync(IAppEntity app, DateTime today)
{
Guard.NotNull(app);
var isLocked = false;
var (plan, _) = appPlansProvider.GetPlanForApp(app);
if (plan.MaxApiCalls > 0 || plan.BlockingApiCalls > 0)
{
var appId = app.Id;
var usage = await apiUsageTracker.GetMonthCostsAsync(appId.ToString(), today);
if (IsAboutToBeLocked(today, plan.MaxApiCalls, usage) && !HasNotifiedBefore(app.Id))
{
var users = app.Contributors.Where(x => x.Value == Role.Owner).Select(x => x.Key).ToArray();
var notification = new UsageNotification
{
AppId = appId,
AppName = app.Name,
Usage = usage,
UsageLimit = plan.BlockingApiCalls,
Users = users
};
usageLimitNotifier.NotifyAsync(notification).Forget();
TrackNotified(appId);
}
isLocked = plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls;
}
return isLocked;
}
private bool HasNotifiedBefore(Guid appId)
{
return memoryCache.Get<bool>(appId);
}
private bool TrackNotified(Guid appId)
{
return memoryCache.Set(appId, true, TimeSpan.FromHours(1));
}
private bool IsAboutToBeLocked(DateTime today, long limit, long usage)
{
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
var forecasted = ((float)usage / today.Day) * daysInMonth;
return forecasted > limit;
}
}
}

24
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotification.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public sealed class UsageNotification
{
public Guid AppId { get; set; }
public string AppName { get; set; }
public long Usage { get; set; }
public long UsageLimit { get; set; }
public string[] Users { get; set; }
}
}

90
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs

@ -0,0 +1,90 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Apps.Notifications;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Email;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public sealed class UsageNotifierGrain : GrainOfString, IUsageNotifierGrain
{
private static readonly TimeSpan TimeBetweenNotifications = TimeSpan.FromHours(12);
private readonly IGrainState<State> state;
private readonly INotificationSender notificationSender;
private readonly IUserResolver userResolver;
[CollectionName("UsageNotifications")]
public sealed class State
{
public Dictionary<Guid, DateTime> NotificationsSent { get; } = new Dictionary<Guid, DateTime>();
}
public UsageNotifierGrain(IGrainState<State> state, INotificationSender notificationSender, IUserResolver userResolver)
{
Guard.NotNull(state);
Guard.NotNull(notificationSender);
Guard.NotNull(userResolver);
this.state = state;
this.notificationSender = notificationSender;
this.userResolver = userResolver;
}
public async Task NotifyAsync(UsageNotification notification)
{
var now = DateTime.UtcNow;
if (!HasBeenSentBefore(notification.AppId, now))
{
if (notificationSender.IsActive)
{
foreach (var userId in notification.Users)
{
var user = await userResolver.FindByIdOrEmailAsync(userId);
if (user != null)
{
notificationSender.SendUsageAsync(user,
notification.AppName,
notification.Usage,
notification.UsageLimit).Forget();
}
}
}
await TrackNotifiedAsync(notification.AppId, now);
}
}
private bool HasBeenSentBefore(Guid appId, DateTime now)
{
if (state.Value.NotificationsSent.TryGetValue(appId, out var lastSent))
{
var elapsed = now - lastSent;
return elapsed > TimeBetweenNotifications;
}
return false;
}
private Task TrackNotifiedAsync(Guid appId, DateTime now)
{
state.Value.NotificationsSent[appId] = now;
return state.WriteAsync();
}
}
}

8
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/INotificationEmailSender.cs → backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs

@ -8,12 +8,14 @@
using System.Threading.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
namespace Squidex.Domain.Apps.Entities.Apps.Notifications
{
public interface INotificationEmailSender
public interface INotificationSender
{
bool IsActive { get; }
Task SendContributorEmailAsync(IUser assigner, IUser assignee, string appName, bool isCreated);
Task SendUsageAsync(IUser user, string appName, long usage, long usageLimit);
Task SendInviteAsync(IUser assigner, IUser user, string appName);
}
}

11
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NoopNotificationEmailSender.cs → backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs

@ -8,16 +8,21 @@
using System.Threading.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
namespace Squidex.Domain.Apps.Entities.Apps.Notifications
{
public sealed class NoopNotificationEmailSender : INotificationEmailSender
public sealed class NoopNotificationSender : INotificationSender
{
public bool IsActive
{
get { return false; }
}
public Task SendContributorEmailAsync(IUser assigner, IUser assignee, string appName, bool isCreated)
public Task SendInviteAsync(IUser assigner, IUser user, string appName)
{
return Task.CompletedTask;
}
public Task SendUsageAsync(IUser user, string appName, long usage, long limit)
{
return Task.CompletedTask;
}

178
backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs

@ -0,0 +1,178 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Email;
using Squidex.Infrastructure.Log;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Notifications
{
public sealed class NotificationEmailSender : INotificationSender
{
private readonly IEmailSender emailSender;
private readonly IEmailUrlGenerator emailUrlGenerator;
private readonly ISemanticLog log;
private readonly NotificationEmailTextOptions texts;
private sealed class TemplatesVars
{
public IUser User { get; set; }
public IUser? Assigner { get; set; }
public string AppName { get; set; }
public long? ApiCalls { get; set; }
public long? ApiCallsLimit { get; set; }
public string URL { get; set; }
}
public bool IsActive
{
get { return true; }
}
public NotificationEmailSender(
IOptions<NotificationEmailTextOptions> texts,
IEmailSender emailSender,
IEmailUrlGenerator emailUrlGenerator,
ISemanticLog log)
{
Guard.NotNull(texts);
Guard.NotNull(emailSender);
Guard.NotNull(emailUrlGenerator);
Guard.NotNull(log);
this.texts = texts.Value;
this.emailSender = emailSender;
this.emailUrlGenerator = emailUrlGenerator;
this.log = log;
}
public Task SendUsageAsync(IUser user, string appName, long usage, long usageLimit)
{
Guard.NotNull(user);
Guard.NotNull(appName);
var vars = new TemplatesVars
{
ApiCalls = usage,
ApiCallsLimit = usageLimit,
AppName = appName
};
return SendEmailAsync("Usage",
texts.UsageSubject,
texts.UsageBody,
user, vars);
}
public Task SendInviteAsync(IUser assigner, IUser user, string appName)
{
Guard.NotNull(assigner);
Guard.NotNull(user);
Guard.NotNull(appName);
var vars = new TemplatesVars { Assigner = assigner, AppName = appName };
if (user.HasConsent())
{
return SendEmailAsync("ExistingUser",
texts.ExistingUserSubject,
texts.ExistingUserBody,
user, vars);
}
else
{
return SendEmailAsync("NewUser",
texts.NewUserSubject,
texts.NewUserBody,
user, vars);
}
}
private async Task SendEmailAsync(string template, string emailSubj, string emailBody, IUser user, TemplatesVars vars)
{
if (string.IsNullOrWhiteSpace(emailBody))
{
LogWarning($"No email subject configured for {template}");
return;
}
if (string.IsNullOrWhiteSpace(emailSubj))
{
LogWarning($"No email body configured for {template}");
return;
}
vars.URL = emailUrlGenerator.GenerateUIUrl();
vars.User = user;
emailSubj = Format(emailSubj, vars);
emailBody = Format(emailBody, vars);
try
{
await emailSender.SendAsync(user.Email, emailSubj, emailBody);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "SendNotification")
.WriteProperty("status", "Failed"));
throw;
}
}
private void LogWarning(string reason)
{
log.LogWarning(w => w
.WriteProperty("action", "SendNotification")
.WriteProperty("status", "Failed")
.WriteProperty("reason", reason));
}
private static string Format(string text, TemplatesVars vars)
{
text = text.Replace("$APP_NAME", vars.AppName);
if (vars.Assigner != null)
{
text = text.Replace("$ASSIGNER_EMAIL", vars.Assigner.Email);
text = text.Replace("$ASSIGNER_NAME", vars.Assigner.DisplayName());
}
if (vars.User != null)
{
text = text.Replace("$USER_EMAIL", vars.User.Email);
text = text.Replace("$USER_NAME", vars.User.DisplayName());
}
if (vars.ApiCallsLimit != null)
{
text = text.Replace("$API_CALLS_LIMIT", vars.ApiCallsLimit.ToString());
}
if (vars.ApiCalls != null)
{
text = text.Replace("$API_CALLS", vars.ApiCalls.ToString());
}
text = text.Replace("$UI_URL", vars.URL);
return text;
}
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailTextOptions.cs → backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailTextOptions.cs

@ -5,10 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
namespace Squidex.Domain.Apps.Entities.Apps.Notifications
{
public sealed class NotificationEmailTextOptions
{
public string UsageSubject { get; set; }
public string UsageBody { get; set; }
public string NewUserSubject { get; set; }
public string NewUserBody { get; set; }

1
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -28,6 +28,7 @@
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00005" />
<PackageReference Include="Lucene.Net.Queries" Version="4.8.0-beta00005" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00005" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.2" />
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>

43
backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs

@ -5,9 +5,12 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mail;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.ObjectPool;
using Microsoft.Extensions.Options;
@ -17,14 +20,14 @@ namespace Squidex.Infrastructure.Email
[ExcludeFromCodeCoverage]
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmptOptions options;
private readonly SmtpOptions options;
private readonly ObjectPool<SmtpClient> clientPool;
internal sealed class SmtpClientPolicy : PooledObjectPolicy<SmtpClient>
{
private readonly SmptOptions options;
private readonly SmtpOptions options;
public SmtpClientPolicy(SmptOptions options)
public SmtpClientPolicy(SmtpOptions options)
{
this.options = options;
}
@ -37,7 +40,9 @@ namespace Squidex.Infrastructure.Email
options.Username,
options.Password),
EnableSsl = options.EnableSsl
EnableSsl = options.EnableSsl,
Timeout = options.Timeout
};
}
@ -47,7 +52,7 @@ namespace Squidex.Infrastructure.Email
}
}
public SmtpEmailSender(IOptions<SmptOptions> options)
public SmtpEmailSender(IOptions<SmtpOptions> options)
{
Guard.NotNull(options);
@ -61,12 +66,38 @@ namespace Squidex.Infrastructure.Email
var smtpClient = clientPool.Get();
try
{
await smtpClient.SendMailAsync(options.Sender, recipient, subject, body);
using (var cts = new CancellationTokenSource(options.Timeout))
{
await CheckConnectionAsync(cts.Token);
using (cts.Token.Register(smtpClient.SendAsyncCancel))
{
await smtpClient.SendMailAsync(options.Sender, recipient, subject, body);
}
}
}
finally
{
clientPool.Return(smtpClient);
}
}
private async Task CheckConnectionAsync(CancellationToken ct)
{
using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
{
var tcs = new TaskCompletionSource<IAsyncResult>();
var state = socket.BeginConnect(options.Server, options.Port, tcs.SetResult, null);
using (ct.Register(() =>
{
tcs.TrySetException(new OperationCanceledException($"Failed to establish a connection to {options.Server}:{options.Port}"));
}))
{
await tcs.Task;
}
}
}
}
}

4
backend/src/Squidex.Infrastructure/Email/SmptOptions.cs → backend/src/Squidex.Infrastructure/Email/SmtpOptions.cs

@ -7,7 +7,7 @@
namespace Squidex.Infrastructure.Email
{
public sealed class SmptOptions
public sealed class SmtpOptions
{
public string Server { get; set; }
@ -19,6 +19,8 @@ namespace Squidex.Infrastructure.Email
public bool EnableSsl { get; set; }
public int Timeout { get; set; } = 5000;
public int Port { get; set; } = 587;
public bool IsConfigured()

21
backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs

@ -12,23 +12,18 @@ using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Web.Pipeline
{
public sealed class ApiCostsFilter : IAsyncActionFilter, IFilterContainer
{
private readonly IAppPlansProvider appPlansProvider;
private readonly IApiUsageTracker usageTracker;
private readonly UsageGate usageGate;
public ApiCostsFilter(IAppPlansProvider appPlansProvider, IApiUsageTracker usageTracker)
public ApiCostsFilter(UsageGate usageGate)
{
Guard.NotNull(appPlansProvider);
Guard.NotNull(usageTracker);
Guard.NotNull(usageGate);
this.appPlansProvider = appPlansProvider;
this.usageTracker = usageTracker;
this.usageGate = usageGate;
}
IFilterMetadata IFilterContainer.FilterDefinition { get; set; }
@ -53,17 +48,13 @@ namespace Squidex.Web.Pipeline
if (app != null)
{
var appId = app.Id.ToString();
if (FilterDefinition.Costs > 0)
{
using (Profiler.Trace("CheckUsage"))
{
var (plan, _) = appPlansProvider.GetPlanForApp(app);
var usage = await usageTracker.GetMonthCostsAsync(appId, DateTime.Today);
var isBlocked = await usageGate.IsBlockedAsync(app, DateTime.Today);
if (plan.BlockingApiCalls >= 0 && usage > plan.BlockingApiCalls)
if (isBlocked)
{
context.Result = new StatusCodeResult(429);
return;

13
backend/src/Squidex/Config/Domain/NotificationsServices.cs

@ -8,7 +8,8 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications;
using Squidex.Domain.Apps.Entities.Apps.Invitation;
using Squidex.Domain.Apps.Entities.Apps.Notifications;
using Squidex.Infrastructure.Email;
using Squidex.Infrastructure.EventSourcing;
@ -18,7 +19,7 @@ namespace Squidex.Config.Domain
{
public static void AddSquidexNotifications(this IServiceCollection services, IConfiguration config)
{
var emailOptions = config.GetSection("email:smtp").Get<SmptOptions>();
var emailOptions = config.GetSection("email:smtp").Get<SmtpOptions>();
if (emailOptions.IsConfigured())
{
@ -31,15 +32,15 @@ namespace Squidex.Config.Domain
.As<IEmailSender>();
services.AddSingletonAs<NotificationEmailSender>()
.AsOptional<INotificationEmailSender>();
.AsOptional<INotificationSender>();
}
else
{
services.AddSingletonAs<NoopNotificationEmailSender>()
.AsOptional<INotificationEmailSender>();
services.AddSingletonAs<NoopNotificationSender>()
.AsOptional<INotificationSender>();
}
services.AddSingletonAs<NotificationEmailEventConsumer>()
services.AddSingletonAs<InvitationEventConsumer>()
.As<IEventConsumer>();
}
}

3
backend/src/Squidex/Config/Domain/SubscriptionServices.cs

@ -27,6 +27,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<NoopAppPlanBillingManager>()
.AsOptional<IAppPlanBillingManager>();
services.AddSingletonAs<UsageGate>()
.AsOptional<IUserEvents>();
services.AddSingletonAs<NoopUserEvents>()
.AsOptional<IUserEvents>();
}

12
backend/src/Squidex/appsettings.json

@ -143,7 +143,7 @@
/*
* The port to your email server.
*/
"port": 465
"port": 587
},
"notifications": {
/*
@ -161,7 +161,15 @@
/*
* The email body when an existing user is added as contributor.
*/
"existingUserBody": "Dear User,\r\n\r\n$ASSIGNER_NAME ($ASSIGNER_EMAIL) has invited you to join App $APP_NAME at Squidex Headless CMS.\r\n\r\nLogin or reload the Management UI to see the App.\r\n\r\nThank you very much,\r\nThe Squidex Team\r\n\r\n<<Start now!>> [$UI_URL]"
"existingUserBody": "Dear User,\r\n\r\n$ASSIGNER_NAME ($ASSIGNER_EMAIL) has invited you to join App $APP_NAME at Squidex Headless CMS.\r\n\r\nLogin or reload the Management UI to see the App.\r\n\r\nThank you very much,\r\nThe Squidex Team\r\n\r\n<<Start now!>> [$UI_URL]",
/*
* The email subject when app usage reached
*/
"usageSubject": "[Squidex CMS] You you are about to reach your usage limit for App $APP_NAME",
/*
* The email body when app usage reached
*/
"usageBody": "Dear User,\r\n\r\nYou you are about to reach your usage limit for App $APP_NAME at Squidex Headless CMS.\r\n\r\nYou have already used $API_CALLS of your monthy limit of $API_CALLS_LIMIT API calls.\r\n\r\nPlease check your clients or upgrade your plan!\r\n\r\n<<Go to Squidex!>> [$UI_URL]"
}
},

21
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/Notifications/NotificationEmailEventConsumerTests.cs → backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEventConsumerTests.cs

@ -9,6 +9,7 @@ using System;
using System.Threading.Tasks;
using FakeItEasy;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps.Notifications;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
@ -18,9 +19,9 @@ using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
{
public class NotificationEmailEventConsumerTests
public class InvitationEventConsumerTests
{
private readonly INotificationEmailSender emailSender = A.Fake<INotificationEmailSender>();
private readonly INotificationSender notificatíonSender = A.Fake<INotificationSender>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly IUser assigner = A.Fake<IUser>();
private readonly IUser assignee = A.Fake<IUser>();
@ -28,11 +29,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
private readonly string assignerId = Guid.NewGuid().ToString();
private readonly string assigneeId = Guid.NewGuid().ToString();
private readonly string appName = "my-app";
private readonly NotificationEmailEventConsumer sut;
private readonly InvitationEventConsumer sut;
public NotificationEmailEventConsumerTests()
public InvitationEventConsumerTests()
{
A.CallTo(() => emailSender.IsActive)
A.CallTo(() => notificatíonSender.IsActive)
.Returns(true);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assignerId))
@ -41,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
A.CallTo(() => userResolver.FindByIdOrEmailAsync(assigneeId))
.Returns(assignee);
sut = new NotificationEmailEventConsumer(emailSender, userResolver, log);
sut = new InvitationEventConsumer(notificatíonSender, userResolver, log);
}
[Fact]
@ -92,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
{
var @event = CreateEvent(RefTokenType.Subject, true);
A.CallTo(() => emailSender.IsActive)
A.CallTo(() => notificatíonSender.IsActive)
.Returns(false);
await sut.On(@event);
@ -136,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
await sut.On(@event);
A.CallTo(() => emailSender.SendContributorEmailAsync(assigner, assignee, appName, true))
A.CallTo(() => notificatíonSender.SendInviteAsync(assigner, assignee, appName))
.MustHaveHappened();
}
@ -147,7 +148,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
await sut.On(@event);
A.CallTo(() => emailSender.SendContributorEmailAsync(assigner, assignee, appName, false))
A.CallTo(() => notificatíonSender.SendInviteAsync(assigner, assignee, appName))
.MustHaveHappened();
}
@ -165,7 +166,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
private void MustNotSendEmail()
{
A.CallTo(() => emailSender.SendContributorEmailAsync(A<IUser>._, A<IUser>._, A<string>._, A<bool>._))
A.CallTo(() => notificatíonSender.SendInviteAsync(A<IUser>._, A<IUser>._, A<string>._))
.MustNotHaveHappened();
}

134
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs

@ -0,0 +1,134 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.UsageTracking;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public class UsageGateTests
{
private readonly IAppEntity appEntity;
private readonly IAppLimitsPlan appPlan = A.Fake<IAppLimitsPlan>();
private readonly IAppPlansProvider appPlansProvider = A.Fake<IAppPlansProvider>();
private readonly IApiUsageTracker usageTracker = A.Fake<IApiUsageTracker>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IUsageNotifierGrain usageNotifierGrain = A.Fake<IUsageNotifierGrain>();
private readonly DateTime today = new DateTime(2020, 04, 10);
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly UsageGate sut;
private long apiCallsBlocking;
private long apiCallsMax;
private long apiCallsCurrent;
public UsageGateTests()
{
appEntity = Mocks.App(appId);
A.CallTo(() => grainFactory.GetGrain<IUsageNotifierGrain>(SingleGrain.Id, null))
.Returns(usageNotifierGrain);
A.CallTo(() => appPlansProvider.GetPlan(null))
.Returns(appPlan);
A.CallTo(() => appPlansProvider.GetPlanForApp(appEntity))
.Returns((appPlan, "free"));
A.CallTo(() => appPlan.MaxApiCalls)
.ReturnsLazily(x => apiCallsMax);
A.CallTo(() => appPlan.BlockingApiCalls)
.ReturnsLazily(x => apiCallsBlocking);
A.CallTo(() => usageTracker.GetMonthCostsAsync(appId.Id.ToString(), today))
.ReturnsLazily(x => Task.FromResult(apiCallsCurrent));
sut = new UsageGate(appPlansProvider, usageTracker, grainFactory);
}
[Fact]
public async Task Should_return_true_if_over_blocking_limt()
{
apiCallsCurrent = 1000;
apiCallsBlocking = 600;
apiCallsMax = 600;
var isBlocked = await sut.IsBlockedAsync(appEntity, today);
Assert.True(isBlocked);
A.CallTo(() => usageNotifierGrain.NotifyAsync(A<UsageNotification>.Ignored))
.MustHaveHappened();
}
[Fact]
public async Task Should_return_false_if_below_blocking_limit()
{
apiCallsCurrent = 100;
apiCallsBlocking = 1600;
apiCallsMax = 1600;
var isBlocked = await sut.IsBlockedAsync(appEntity, today);
Assert.False(isBlocked);
A.CallTo(() => usageNotifierGrain.NotifyAsync(A<UsageNotification>.Ignored))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_return_false_and_notify_if_about_to_over_included_contingent()
{
apiCallsCurrent = 1200; // * 30 days = 3600
apiCallsBlocking = 5000;
apiCallsMax = 3000;
var isBlocked = await sut.IsBlockedAsync(appEntity, today);
Assert.False(isBlocked);
A.CallTo(() => usageNotifierGrain.NotifyAsync(A<UsageNotification>.Ignored))
.MustHaveHappened();
}
[Fact]
public async Task Should_return_false_and_notify_if_about_to_over_included_contingent_but_no_max_given()
{
apiCallsCurrent = 1200; // * 30 days = 3600
apiCallsBlocking = 5000;
apiCallsMax = 0;
var isBlocked = await sut.IsBlockedAsync(appEntity, today);
Assert.False(isBlocked);
A.CallTo(() => usageNotifierGrain.NotifyAsync(A<UsageNotification>.Ignored))
.MustHaveHappened();
}
[Fact]
public async Task Should_only_notify_once_if_about_to_be_over_included_contingent()
{
apiCallsCurrent = 1200; // * 30 days = 3600
apiCallsBlocking = 5000;
apiCallsMax = 3000;
await sut.IsBlockedAsync(appEntity, today);
await sut.IsBlockedAsync(appEntity, today);
A.CallTo(() => usageNotifierGrain.NotifyAsync(A<UsageNotification>.Ignored))
.MustHaveHappenedOnceExactly();
}
}
}

84
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/Notifications/NotificationEmailSenderTests.cs → backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/NotificationEmailSenderTests.cs

@ -17,14 +17,14 @@ using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
namespace Squidex.Domain.Apps.Entities.Apps.Notifications
{
public class NotificationEmailSenderTests
{
private readonly IEmailSender emailSender = A.Fake<IEmailSender>();
private readonly IEmailUrlGenerator emailUrlGenerator = A.Fake<IEmailUrlGenerator>();
private readonly IUser assigner = A.Fake<IUser>();
private readonly IUser assignee = A.Fake<IUser>();
private readonly IUser user = A.Fake<IUser>();
private readonly ISemanticLog log = A.Fake<ISemanticLog>();
private readonly List<Claim> assignerClaims = new List<Claim> { new Claim(SquidexClaimTypes.DisplayName, "Sebastian Stehle") };
private readonly List<Claim> assigneeClaims = new List<Claim> { new Claim(SquidexClaimTypes.DisplayName, "Qaisar Ahmad") };
@ -40,9 +40,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
A.CallTo(() => assigner.Claims)
.Returns(assignerClaims);
A.CallTo(() => assignee.Email)
A.CallTo(() => user.Email)
.Returns("qaisar@squidex.io");
A.CallTo(() => assignee.Claims)
A.CallTo(() => user.Claims)
.Returns(assigneeClaims);
A.CallTo(() => emailUrlGenerator.GenerateUIUrl())
@ -54,83 +54,117 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation.Notifications
[Fact]
public async Task Should_format_assigner_email_and_send_email()
{
await TestFormattingAsync("Email: $ASSIGNER_EMAIL", "Email: sebastian@squidex.io");
await TestInvitationFormattingAsync("Email: $ASSIGNER_EMAIL", "Email: sebastian@squidex.io");
}
[Fact]
public async Task Should_format_assignee_email_and_send_email()
public async Task Should_format_assigner_name_and_send_email()
{
await TestFormattingAsync("Email: $ASSIGNEE_EMAIL", "Email: qaisar@squidex.io");
await TestInvitationFormattingAsync("Name: $ASSIGNER_NAME", "Name: Sebastian Stehle");
}
[Fact]
public async Task Should_format_assigner_name_and_send_email()
public async Task Should_format_user_email_and_send_email()
{
await TestFormattingAsync("Name: $ASSIGNER_NAME", "Name: Sebastian Stehle");
await TestInvitationFormattingAsync("Email: $USER_EMAIL", "Email: qaisar@squidex.io");
}
[Fact]
public async Task Should_format_assignee_name_and_send_email()
public async Task Should_format_user_name_and_send_email()
{
await TestFormattingAsync("Name: $ASSIGNEE_NAME", "Name: Qaisar Ahmad");
await TestInvitationFormattingAsync("Name: $USER_NAME", "Name: Qaisar Ahmad");
}
[Fact]
public async Task Should_format_app_name_and_send_email()
{
await TestFormattingAsync("App: $APP_NAME", "App: my-app");
await TestInvitationFormattingAsync("App: $APP_NAME", "App: my-app");
}
[Fact]
public async Task Should_format_ui_url_and_send_email()
{
await TestFormattingAsync("UI: $UI_URL", "UI: my-ui");
await TestInvitationFormattingAsync("UI: $UI_URL", "UI: my-ui");
}
[Fact]
public async Task Should_format_api_calls_and_send_email()
{
await TestUsageFormattingAsync("ApiCalls: $API_CALLS", "ApiCalls: 100");
}
[Fact]
public async Task Should_format_api_calls_limit_and_send_email()
{
await TestUsageFormattingAsync("ApiCallsLimit: $API_CALLS_LIMIT", "ApiCallsLimit: 120");
}
[Fact]
public async Task Should_not_send_invitation_email_if_texts_for_new_user_are_empty()
{
await sut.SendInviteAsync(assigner, user, appName);
A.CallTo(() => emailSender.SendAsync(user.Email, A<string>._, A<string>._))
.MustNotHaveHappened();
MustLogWarning();
}
[Fact]
public async Task Should_not_send_email_if_texts_for_new_user_are_empty()
public async Task Should_not_send_invitation_email_if_texts_for_existing_user_are_empty()
{
await sut.SendContributorEmailAsync(assigner, assignee, appName, true);
await sut.SendInviteAsync(assigner, user, appName);
A.CallTo(() => emailSender.SendAsync(assignee.Email, A<string>._, A<string>._))
A.CallTo(() => emailSender.SendAsync(user.Email, A<string>._, A<string>._))
.MustNotHaveHappened();
MustLogWarning();
}
[Fact]
public async Task Should_not_send_email_if_texts_for_existing_user_are_empty()
public async Task Should_not_send_usage_email_if_texts_empty()
{
await sut.SendContributorEmailAsync(assigner, assignee, appName, true);
await sut.SendUsageAsync(user, appName, 100, 120);
A.CallTo(() => emailSender.SendAsync(assignee.Email, A<string>._, A<string>._))
A.CallTo(() => emailSender.SendAsync(user.Email, A<string>._, A<string>._))
.MustNotHaveHappened();
MustLogWarning();
}
[Fact]
public async Task Should_send_email_when_consent_given()
public async Task Should_send_invitation_email_when_consent_given()
{
assigneeClaims.Add(new Claim(SquidexClaimTypes.Consent, "True"));
texts.ExistingUserSubject = "email-subject";
texts.ExistingUserBody = "email-body";
await sut.SendContributorEmailAsync(assigner, assignee, appName, true);
await sut.SendInviteAsync(assigner, user, appName);
A.CallTo(() => emailSender.SendAsync(user.Email, "email-subject", "email-body"))
.MustHaveHappened();
}
private async Task TestUsageFormattingAsync(string pattern, string result)
{
texts.UsageSubject = pattern;
texts.UsageBody = pattern;
await sut.SendUsageAsync(user, appName, 100, 120);
A.CallTo(() => emailSender.SendAsync(assignee.Email, "email-subject", "email-body"))
A.CallTo(() => emailSender.SendAsync(user.Email, result, result))
.MustHaveHappened();
}
private async Task TestFormattingAsync(string pattern, string result)
private async Task TestInvitationFormattingAsync(string pattern, string result)
{
texts.NewUserSubject = pattern;
texts.NewUserBody = pattern;
await sut.SendContributorEmailAsync(assigner, assignee, appName, true);
await sut.SendInviteAsync(assigner, user, appName);
A.CallTo(() => emailSender.SendAsync(assignee.Email, result, result))
A.CallTo(() => emailSender.SendAsync(user.Email, result, result))
.MustHaveHappened();
}

33
backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Xunit;
namespace Squidex.Infrastructure.Email
{
public class SmtpEmailSenderTests
{
[Fact]
public async Task Should_handle_timeout_properly()
{
var sut = new SmtpEmailSender(Options.Create(new SmtpOptions
{
Sender = "sebastian@squidex.io",
Server = "invalid",
Timeout = 1000
}));
var timer = Task.Delay(5000);
var result = await Task.WhenAny(timer, sut.SendAsync("hello@squidex.io", "TEST", "TEST"));
Assert.NotSame(timer, result);
}
}
}

58
backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs

@ -16,7 +16,6 @@ using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure.UsageTracking;
using Xunit;
namespace Squidex.Web.Pipeline
@ -24,15 +23,11 @@ namespace Squidex.Web.Pipeline
public class ApiCostsFilterTests
{
private readonly IAppEntity appEntity = A.Fake<IAppEntity>();
private readonly IAppLimitsPlan appPlan = A.Fake<IAppLimitsPlan>();
private readonly IAppPlansProvider appPlansProvider = A.Fake<IAppPlansProvider>();
private readonly IApiUsageTracker usageTracker = A.Fake<IApiUsageTracker>();
private readonly UsageGate usageGate = A.Fake<UsageGate>();
private readonly ActionExecutingContext actionContext;
private readonly HttpContext httpContext = new DefaultHttpContext();
private readonly ActionExecutionDelegate next;
private readonly HttpContext httpContext = new DefaultHttpContext();
private readonly ApiCostsFilter sut;
private long apiCallsBlocking;
private long apiCallsCurrent;
private bool isNextCalled;
public ApiCostsFilterTests()
@ -43,18 +38,6 @@ namespace Squidex.Web.Pipeline
new ActionDescriptor()),
new List<IFilterMetadata>(), new Dictionary<string, object>(), null);
A.CallTo(() => appPlansProvider.GetPlan(null))
.Returns(appPlan);
A.CallTo(() => appPlansProvider.GetPlanForApp(appEntity))
.Returns((appPlan, "free"));
A.CallTo(() => appPlan.BlockingApiCalls)
.ReturnsLazily(x => apiCallsBlocking);
A.CallTo(() => usageTracker.GetMonthCostsAsync(A<string>._, DateTime.Today))
.ReturnsLazily(x => Task.FromResult(apiCallsCurrent));
next = () =>
{
isNextCalled = true;
@ -62,18 +45,18 @@ namespace Squidex.Web.Pipeline
return Task.FromResult<ActionExecutedContext?>(null);
};
sut = new ApiCostsFilter(appPlansProvider, usageTracker);
sut = new ApiCostsFilter(usageGate);
}
[Fact]
public async Task Should_return_429_status_code_if_max_calls_over_blocking_limit()
public async Task Should_return_429_status_code_if_blocked()
{
sut.FilterDefinition = new ApiCostsAttribute(1);
SetupApp();
apiCallsCurrent = 1000;
apiCallsBlocking = 600;
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today))
.Returns(true);
await sut.OnActionExecutionAsync(actionContext, next);
@ -82,14 +65,14 @@ namespace Squidex.Web.Pipeline
}
[Fact]
public async Task Should_track_if_calls_left()
public async Task Should_continue_if_not_blocked()
{
sut.FilterDefinition = new ApiCostsAttribute(13);
SetupApp();
apiCallsCurrent = 1000;
apiCallsBlocking = 1600;
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today))
.Returns(false);
await sut.OnActionExecutionAsync(actionContext, next);
@ -97,18 +80,31 @@ namespace Squidex.Web.Pipeline
}
[Fact]
public async Task Should_not_allow_small_buffer()
public async Task Should_continue_if_costs_are_zero()
{
sut.FilterDefinition = new ApiCostsAttribute(13);
sut.FilterDefinition = new ApiCostsAttribute(0);
SetupApp();
apiCallsCurrent = 1099;
apiCallsBlocking = 1000;
await sut.OnActionExecutionAsync(actionContext, next);
Assert.True(isNextCalled);
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_continue_if_not_app_request()
{
sut.FilterDefinition = new ApiCostsAttribute(12);
await sut.OnActionExecutionAsync(actionContext, next);
Assert.False(isNextCalled);
Assert.True(isNextCalled);
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today))
.MustNotHaveHappened();
}
private void SetupApp()

Loading…
Cancel
Save