diff --git a/backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs index c4c4fb196..69e9726a2 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs +++ b/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 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(); + + 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 diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailEventConsumer.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs similarity index 86% rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailEventConsumer.cs rename to backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs index 67d89fc15..106df9bd4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailEventConsumer.cs +++ b/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); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailSender.cs deleted file mode 100644 index 66b74e8c0..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailSender.cs +++ /dev/null @@ -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 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; - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IUsageNotifierGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IUsageNotifierGrain.cs new file mode 100644 index 000000000..1dde6db2f --- /dev/null +++ b/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); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs new file mode 100644 index 000000000..e6a837209 --- /dev/null +++ b/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(SingleGrain.Id); + } + + public virtual async Task 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(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; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotification.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotification.cs new file mode 100644 index 000000000..5aff26d9f --- /dev/null +++ b/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; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierGrain.cs new file mode 100644 index 000000000..43d692104 --- /dev/null +++ b/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; + private readonly INotificationSender notificationSender; + private readonly IUserResolver userResolver; + + [CollectionName("UsageNotifications")] + public sealed class State + { + public Dictionary NotificationsSent { get; } = new Dictionary(); + } + + public UsageNotifierGrain(IGrainState 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(); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/INotificationEmailSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs similarity index 64% rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/INotificationEmailSender.cs rename to backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs index 052ffd06f..0ea909ec3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/INotificationEmailSender.cs +++ b/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); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NoopNotificationEmailSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs similarity index 62% rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NoopNotificationEmailSender.cs rename to backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs index e78b83f0f..00f18548e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NoopNotificationEmailSender.cs +++ b/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; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs new file mode 100644 index 000000000..73f7518ab --- /dev/null +++ b/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 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; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailTextOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailTextOptions.cs similarity index 80% rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailTextOptions.cs rename to backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailTextOptions.cs index 1d2bdfbbf..3a7d392c9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/Notifications/NotificationEmailTextOptions.cs +++ b/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; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index d33c44c6c..3bc7fb9af 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -28,6 +28,7 @@ + all runtime; build; native; contentfiles; analyzers diff --git a/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs b/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs index 60c441701..9a0108471 100644 --- a/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs +++ b/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 clientPool; internal sealed class SmtpClientPolicy : PooledObjectPolicy { - 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 options) + public SmtpEmailSender(IOptions 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(); + + 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; + } + } + } } } diff --git a/backend/src/Squidex.Infrastructure/Email/SmptOptions.cs b/backend/src/Squidex.Infrastructure/Email/SmtpOptions.cs similarity index 92% rename from backend/src/Squidex.Infrastructure/Email/SmptOptions.cs rename to backend/src/Squidex.Infrastructure/Email/SmtpOptions.cs index 847d9f358..a9e2610e3 100644 --- a/backend/src/Squidex.Infrastructure/Email/SmptOptions.cs +++ b/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() diff --git a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs index abc476c8a..08a8e6e79 100644 --- a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs +++ b/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; diff --git a/backend/src/Squidex/Config/Domain/NotificationsServices.cs b/backend/src/Squidex/Config/Domain/NotificationsServices.cs index 3a9889866..18660e001 100644 --- a/backend/src/Squidex/Config/Domain/NotificationsServices.cs +++ b/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(); + var emailOptions = config.GetSection("email:smtp").Get(); if (emailOptions.IsConfigured()) { @@ -31,15 +32,15 @@ namespace Squidex.Config.Domain .As(); services.AddSingletonAs() - .AsOptional(); + .AsOptional(); } else { - services.AddSingletonAs() - .AsOptional(); + services.AddSingletonAs() + .AsOptional(); } - services.AddSingletonAs() + services.AddSingletonAs() .As(); } } diff --git a/backend/src/Squidex/Config/Domain/SubscriptionServices.cs b/backend/src/Squidex/Config/Domain/SubscriptionServices.cs index acb3ea2a4..949faf170 100644 --- a/backend/src/Squidex/Config/Domain/SubscriptionServices.cs +++ b/backend/src/Squidex/Config/Domain/SubscriptionServices.cs @@ -27,6 +27,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsOptional(); + services.AddSingletonAs() + .AsOptional(); + services.AddSingletonAs() .AsOptional(); } diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index fc1493c6a..d7a5b3f28 100644 --- a/backend/src/Squidex/appsettings.json +++ b/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<> [$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<> [$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<> [$UI_URL]" } }, diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/Notifications/NotificationEmailEventConsumerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEventConsumerTests.cs similarity index 87% rename from backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/Notifications/NotificationEmailEventConsumerTests.cs rename to backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InvitationEventConsumerTests.cs index 4dc1a59d1..4a535fc75 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/Notifications/NotificationEmailEventConsumerTests.cs +++ b/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(); + private readonly INotificationSender notificatíonSender = A.Fake(); private readonly IUserResolver userResolver = A.Fake(); private readonly IUser assigner = A.Fake(); private readonly IUser assignee = A.Fake(); @@ -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._, A._, A._, A._)) + A.CallTo(() => notificatíonSender.SendInviteAsync(A._, A._, A._)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs new file mode 100644 index 000000000..968dc7e5e --- /dev/null +++ b/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(); + private readonly IAppPlansProvider appPlansProvider = A.Fake(); + private readonly IApiUsageTracker usageTracker = A.Fake(); + private readonly IGrainFactory grainFactory = A.Fake(); + private readonly IUsageNotifierGrain usageNotifierGrain = A.Fake(); + private readonly DateTime today = new DateTime(2020, 04, 10); + private readonly NamedId 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(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.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.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.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.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.Ignored)) + .MustHaveHappenedOnceExactly(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/Notifications/NotificationEmailSenderTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/NotificationEmailSenderTests.cs similarity index 53% rename from backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/Notifications/NotificationEmailSenderTests.cs rename to backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/NotificationEmailSenderTests.cs index 439f36f38..c453ae839 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/Notifications/NotificationEmailSenderTests.cs +++ b/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(); private readonly IEmailUrlGenerator emailUrlGenerator = A.Fake(); private readonly IUser assigner = A.Fake(); - private readonly IUser assignee = A.Fake(); + private readonly IUser user = A.Fake(); private readonly ISemanticLog log = A.Fake(); private readonly List assignerClaims = new List { new Claim(SquidexClaimTypes.DisplayName, "Sebastian Stehle") }; private readonly List assigneeClaims = new List { 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._, A._)) + .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._, A._)) + A.CallTo(() => emailSender.SendAsync(user.Email, A._, A._)) .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._, A._)) + A.CallTo(() => emailSender.SendAsync(user.Email, A._, A._)) .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(); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Email/SmtpEmailSenderTests.cs new file mode 100644 index 000000000..9f4986620 --- /dev/null +++ b/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); + } + } +} diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs index 734c89b5a..e0a5563cf 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs +++ b/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(); - private readonly IAppLimitsPlan appPlan = A.Fake(); - private readonly IAppPlansProvider appPlansProvider = A.Fake(); - private readonly IApiUsageTracker usageTracker = A.Fake(); + private readonly UsageGate usageGate = A.Fake(); 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(), new Dictionary(), 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._, DateTime.Today)) - .ReturnsLazily(x => Task.FromResult(apiCallsCurrent)); - next = () => { isNextCalled = true; @@ -62,18 +45,18 @@ namespace Squidex.Web.Pipeline return Task.FromResult(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()