diff --git a/src/Squidex.Read.MongoDb/Apps/MongoAppEntity.cs b/src/Squidex.Read.MongoDb/Apps/MongoAppEntity.cs index d729fd7c8..40995eddf 100644 --- a/src/Squidex.Read.MongoDb/Apps/MongoAppEntity.cs +++ b/src/Squidex.Read.MongoDb/Apps/MongoAppEntity.cs @@ -32,6 +32,10 @@ namespace Squidex.Read.MongoDb.Apps [BsonElement] public long Version { get; set; } + [BsonIgnoreIfDefault] + [BsonElement] + public int PlanId { get; set; } + [BsonRequired] [BsonElement] public string MasterLanguage { get; set; } diff --git a/src/Squidex.Read/Apps/IAppEntity.cs b/src/Squidex.Read/Apps/IAppEntity.cs index cb6ed959a..71f69c609 100644 --- a/src/Squidex.Read/Apps/IAppEntity.cs +++ b/src/Squidex.Read/Apps/IAppEntity.cs @@ -15,6 +15,8 @@ namespace Squidex.Read.Apps { string Name { get; } + int PlanId { get; } + LanguagesConfig LanguagesConfig { get; } IReadOnlyCollection Clients { get; } diff --git a/src/Squidex.Read/Apps/Services/IAppLimitsPlan.cs b/src/Squidex.Read/Apps/Services/IAppLimitsPlan.cs new file mode 100644 index 000000000..626677c33 --- /dev/null +++ b/src/Squidex.Read/Apps/Services/IAppLimitsPlan.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// IAppLimitsPlan.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Read.Apps.Services +{ + public interface IAppLimitsPlan + { + string Name { get; } + + long MaxApiCalls { get; } + + long MaxAssetSize { get; } + + int MaxContributors { get; } + } +} \ No newline at end of file diff --git a/src/Squidex.Read/Apps/Services/IAppLimitsProvider.cs b/src/Squidex.Read/Apps/Services/IAppLimitsProvider.cs new file mode 100644 index 000000000..cffc68ae4 --- /dev/null +++ b/src/Squidex.Read/Apps/Services/IAppLimitsProvider.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// IAppLimitsProvider.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Read.Apps.Services +{ + public interface IAppLimitsProvider + { + IEnumerable GetAvailablePlans(); + + IAppLimitsPlan GetPlanForApp(IAppEntity entity); + + IAppLimitsPlan GetPlan(int planId); + } +} diff --git a/src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsPlan.cs b/src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsPlan.cs new file mode 100644 index 000000000..811a47642 --- /dev/null +++ b/src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsPlan.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// ConfigAppLimitsPlan.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Read.Apps.Services.Implementations +{ + public sealed class ConfigAppLimitsPlan : IAppLimitsPlan + { + public string Name { get; set; } + + public long MaxApiCalls { get; set; } + + public long MaxAssetSize { get; set; } + + public int MaxContributors { get; set; } + + public ConfigAppLimitsPlan Clone() + { + return (ConfigAppLimitsPlan)MemberwiseClone(); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsProvider.cs b/src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsProvider.cs new file mode 100644 index 000000000..2699139bb --- /dev/null +++ b/src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsProvider.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// ConfigAppLimitsProvider.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Infrastructure; + +namespace Squidex.Read.Apps.Services.Implementations +{ + public sealed class ConfigAppLimitsProvider : IAppLimitsProvider + { + private static readonly ConfigAppLimitsPlan Infinite = new ConfigAppLimitsPlan + { + Name = "Infinite", + MaxApiCalls = -1, + MaxAssetSize = -1, + MaxContributors = -1 + }; + + private readonly List config; + + public ConfigAppLimitsProvider(IEnumerable config) + { + Guard.NotNull(config, nameof(config)); + + this.config = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToList(); + } + + public IEnumerable GetAvailablePlans() + { + return config; + } + + public IAppLimitsPlan GetPlanForApp(IAppEntity app) + { + Guard.NotNull(app, nameof(app)); + + return GetPlan(app.PlanId); + } + + public IAppLimitsPlan GetPlan(int planId) + { + if (planId >= 0 && planId < config.Count) + { + return config[planId]; + } + + return config.FirstOrDefault() ?? Infinite; + } + } +} diff --git a/src/Squidex.Write/Apps/AppCommandHandler.cs b/src/Squidex.Write/Apps/AppCommandHandler.cs index c449247a4..b8befd932 100644 --- a/src/Squidex.Write/Apps/AppCommandHandler.cs +++ b/src/Squidex.Write/Apps/AppCommandHandler.cs @@ -12,21 +12,26 @@ using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.Tasks; using Squidex.Read.Apps.Repositories; +using Squidex.Read.Apps.Services; using Squidex.Read.Users.Repositories; using Squidex.Write.Apps.Commands; +// ReSharper disable InvertIf + namespace Squidex.Write.Apps { public class AppCommandHandler : ICommandHandler { private readonly IAggregateHandler handler; private readonly IAppRepository appRepository; + private readonly IAppLimitsProvider appLimitsProvider; private readonly IUserRepository userRepository; private readonly ClientKeyGenerator keyGenerator; public AppCommandHandler( IAggregateHandler handler, IAppRepository appRepository, + IAppLimitsProvider appLimitsProvider, IUserRepository userRepository, ClientKeyGenerator keyGenerator) { @@ -34,11 +39,13 @@ namespace Squidex.Write.Apps Guard.NotNull(keyGenerator, nameof(keyGenerator)); Guard.NotNull(appRepository, nameof(appRepository)); Guard.NotNull(userRepository, nameof(userRepository)); + Guard.NotNull(appLimitsProvider, nameof(appLimitsProvider)); this.handler = handler; this.keyGenerator = keyGenerator; - this.appRepository = appRepository; this.userRepository = userRepository; + this.appRepository = appRepository; + this.appLimitsProvider = appLimitsProvider; } protected async Task On(CreateApp command, CommandContext context) @@ -71,7 +78,20 @@ namespace Squidex.Write.Apps throw new ValidationException("Cannot assign contributor to app", error); } - await handler.UpdateAsync(context, a => a.AssignContributor(command)); + await handler.UpdateAsync(context, a => + { + var oldContributors = a.ContributorCount; + var maxContributors = appLimitsProvider.GetPlan(a.PlanId).MaxContributors; + + a.AssignContributor(command); + + if (a.ContributorCount > oldContributors && a.ContributorCount > maxContributors) + { + var error = new ValidationError("You have reached your max number of contributors"); + + throw new ValidationException("Cannot assign contributor to app", error); + } + }); } protected Task On(AttachClient command, CommandContext context) diff --git a/src/Squidex.Write/Apps/AppContributors.cs b/src/Squidex.Write/Apps/AppContributors.cs index 06ea7a56c..dcc7c3ec2 100644 --- a/src/Squidex.Write/Apps/AppContributors.cs +++ b/src/Squidex.Write/Apps/AppContributors.cs @@ -20,6 +20,11 @@ namespace Squidex.Write.Apps { private readonly Dictionary contributors = new Dictionary(); + public int Count + { + get { return contributors.Count; } + } + public void Assign(string contributorId, PermissionLevel permission) { string Message() => "Cannot assign contributor"; diff --git a/src/Squidex.Write/Apps/AppDomainObject.cs b/src/Squidex.Write/Apps/AppDomainObject.cs index 1fbe9917c..d7bb4c902 100644 --- a/src/Squidex.Write/Apps/AppDomainObject.cs +++ b/src/Squidex.Write/Apps/AppDomainObject.cs @@ -36,6 +36,16 @@ namespace Squidex.Write.Apps get { return name; } } + public int PlanId + { + get { return 0; } + } + + public int ContributorCount + { + get { return contributors.Count; } + } + public IReadOnlyDictionary Clients { get { return clients.Clients; } diff --git a/src/Squidex/Config/Domain/ReadModule.cs b/src/Squidex/Config/Domain/ReadModule.cs index 6e2b0d96b..3d5047adb 100644 --- a/src/Squidex/Config/Domain/ReadModule.cs +++ b/src/Squidex/Config/Domain/ReadModule.cs @@ -6,8 +6,11 @@ // All rights reserved. // ========================================================================== +using System.Collections.Generic; +using System.Linq; using Autofac; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using Squidex.Read.Apps; using Squidex.Read.Apps.Services; using Squidex.Read.Apps.Services.Implementations; @@ -33,11 +36,21 @@ namespace Squidex.Config.Domain protected override void Load(ContainerBuilder builder) { + builder.Register(c => c.Resolve>().Value?.Plans ?? Enumerable.Empty()) + .As>() + .AsSelf() + .SingleInstance(); + builder.RegisterType() .As() .AsSelf() .SingleInstance(); + builder.RegisterType() + .As() + .AsSelf() + .SingleInstance(); + builder.RegisterType() .As() .AsSelf() diff --git a/src/Squidex/Config/MyUsageOptions.cs b/src/Squidex/Config/MyUsageOptions.cs new file mode 100644 index 000000000..8efeaed82 --- /dev/null +++ b/src/Squidex/Config/MyUsageOptions.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// MyUsageOptions.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Read.Apps.Services.Implementations; + +namespace Squidex.Config +{ + public class MyUsageOptions + { + public ConfigAppLimitsPlan[] Plans { get; set; } + } +} diff --git a/src/Squidex/Controllers/Api/Apps/AppClientsController.cs b/src/Squidex/Controllers/Api/Apps/AppClientsController.cs index c74ec0792..be7643d29 100644 --- a/src/Squidex/Controllers/Api/Apps/AppClientsController.cs +++ b/src/Squidex/Controllers/Api/Apps/AppClientsController.cs @@ -15,7 +15,6 @@ using Squidex.Controllers.Api.Apps.Models; using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; -using Squidex.Read.Apps.Services; using Squidex.Write.Apps; using Squidex.Write.Apps.Commands; @@ -30,12 +29,9 @@ namespace Squidex.Controllers.Api.Apps [SwaggerTag("Apps")] public class AppClientsController : ControllerBase { - private readonly IAppProvider appProvider; - - public AppClientsController(ICommandBus commandBus, IAppProvider appProvider) + public AppClientsController(ICommandBus commandBus) : base(commandBus) { - this.appProvider = appProvider; } /// @@ -53,18 +49,11 @@ namespace Squidex.Controllers.Api.Apps [Route("apps/{app}/clients/")] [ProducesResponseType(typeof(ClientDto[]), 200)] [ApiCosts(1)] - public async Task GetClients(string app) + public IActionResult GetClients(string app) { - var entity = await appProvider.FindAppByNameAsync(app); - - if (entity == null) - { - return NotFound(); - } - - var response = entity.Clients.Select(x => SimpleMapper.Map(x, new ClientDto())).ToList(); + var response = App.Clients.Select(x => SimpleMapper.Map(x, new ClientDto())).ToList(); - Response.Headers["ETag"] = new StringValues(entity.Version.ToString()); + Response.Headers["ETag"] = new StringValues(App.Version.ToString()); return Ok(response); } diff --git a/src/Squidex/Controllers/Api/Apps/AppContributorsController.cs b/src/Squidex/Controllers/Api/Apps/AppContributorsController.cs index 5af3da83f..32a58635d 100644 --- a/src/Squidex/Controllers/Api/Apps/AppContributorsController.cs +++ b/src/Squidex/Controllers/Api/Apps/AppContributorsController.cs @@ -29,12 +29,12 @@ namespace Squidex.Controllers.Api.Apps [SwaggerTag("Apps")] public class AppContributorsController : ControllerBase { - private readonly IAppProvider appProvider; + private readonly IAppLimitsProvider appLimitsProvider; - public AppContributorsController(ICommandBus commandBus, IAppProvider appProvider) + public AppContributorsController(ICommandBus commandBus, IAppLimitsProvider appLimitsProvider) : base(commandBus) { - this.appProvider = appProvider; + this.appLimitsProvider = appLimitsProvider; } /// @@ -47,20 +47,15 @@ namespace Squidex.Controllers.Api.Apps /// [HttpGet] [Route("apps/{app}/contributors/")] - [ProducesResponseType(typeof(ContributorDto[]), 200)] + [ProducesResponseType(typeof(ContributorsDto), 200)] [ApiCosts(1)] - public async Task GetContributors(string app) + public IActionResult GetContributors(string app) { - var entity = await appProvider.FindAppByNameAsync(app); + var contributors = App.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToArray(); - if (entity == null) - { - return NotFound(); - } + var response = new ContributorsDto { Contributors = contributors, MaxContributors = appLimitsProvider.GetPlanForApp(App).MaxContributors }; - var response = entity.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToList(); - - Response.Headers["ETag"] = new StringValues(entity.Version.ToString()); + Response.Headers["ETag"] = new StringValues(App.Version.ToString()); return Ok(response); } diff --git a/src/Squidex/Controllers/Api/Apps/AppLanguagesController.cs b/src/Squidex/Controllers/Api/Apps/AppLanguagesController.cs index f235623c9..0f8b85314 100644 --- a/src/Squidex/Controllers/Api/Apps/AppLanguagesController.cs +++ b/src/Squidex/Controllers/Api/Apps/AppLanguagesController.cs @@ -19,7 +19,6 @@ using Squidex.Controllers.Api.Apps.Models; using Squidex.Core; using Squidex.Infrastructure; using Squidex.Pipeline; -using Squidex.Read.Apps.Services; using Squidex.Write.Apps.Commands; namespace Squidex.Controllers.Api.Apps @@ -32,12 +31,9 @@ namespace Squidex.Controllers.Api.Apps [SwaggerTag("Apps")] public class AppLanguagesController : ControllerBase { - private readonly IAppProvider appProvider; - - public AppLanguagesController(ICommandBus commandBus, IAppProvider appProvider) + public AppLanguagesController(ICommandBus commandBus) : base(commandBus) { - this.appProvider = appProvider; } /// @@ -52,25 +48,18 @@ namespace Squidex.Controllers.Api.Apps [HttpGet] [Route("apps/{app}/languages/")] [ProducesResponseType(typeof(LanguageDto[]), 200)] - public async Task GetLanguages(string app) + public IActionResult GetLanguages(string app) { - var entity = await appProvider.FindAppByNameAsync(app); - - if (entity == null) - { - return NotFound(); - } - - var model = entity.LanguagesConfig.OfType().Select(x => + var model = App.LanguagesConfig.OfType().Select(x => SimpleMapper.Map(x.Language, new AppLanguageDto { - IsMaster = x == entity.LanguagesConfig.Master, + IsMaster = x == App.LanguagesConfig.Master, IsOptional = x.IsOptional, Fallback = x.Fallback.ToList() })).OrderByDescending(x => x.IsMaster).ThenBy(x => x.Iso2Code).ToList(); - Response.Headers["ETag"] = new StringValues(entity.Version.ToString()); + Response.Headers["ETag"] = new StringValues(App.Version.ToString()); return Ok(model); } diff --git a/src/Squidex/Controllers/Api/Apps/Models/ContributorsDto.cs b/src/Squidex/Controllers/Api/Apps/Models/ContributorsDto.cs new file mode 100644 index 000000000..96769079b --- /dev/null +++ b/src/Squidex/Controllers/Api/Apps/Models/ContributorsDto.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// ContributorsDto.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Controllers.Api.Apps.Models +{ + public class ContributorsDto + { + /// + /// The contributors. + /// + [Required] + public ContributorDto[] Contributors { get; set; } + + /// + /// The maximum number of allowed contributors. + /// + public int MaxContributors { get; set; } + } +} diff --git a/src/Squidex/Controllers/Api/Assets/AssetsController.cs b/src/Squidex/Controllers/Api/Assets/AssetsController.cs index 46eeeb035..d800c9350 100644 --- a/src/Squidex/Controllers/Api/Assets/AssetsController.cs +++ b/src/Squidex/Controllers/Api/Assets/AssetsController.cs @@ -21,6 +21,7 @@ using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; +using Squidex.Read.Apps.Services; using Squidex.Read.Assets.Repositories; using Squidex.Write.Assets.Commands; @@ -36,16 +37,22 @@ namespace Squidex.Controllers.Api.Assets public class AssetsController : ControllerBase { private readonly IAssetRepository assetRepository; + private readonly IAssetStatsRepository assetStatsRepository; + private readonly IAppLimitsProvider appLimitProvider; private readonly AssetConfig assetsConfig; public AssetsController( ICommandBus commandBus, IAssetRepository assetRepository, + IAssetStatsRepository assetStatsRepository, + IAppLimitsProvider appLimitProvider, IOptions assetsConfig) : base(commandBus) { this.assetsConfig = assetsConfig.Value; this.assetRepository = assetRepository; + this.assetStatsRepository = assetStatsRepository; + this.appLimitProvider = appLimitProvider; } /// @@ -149,7 +156,7 @@ namespace Squidex.Controllers.Api.Assets [ProducesResponseType(typeof(ErrorDto), 400)] public async Task PostAsset(string app, List file) { - var assetFile = GetAssetFile(file); + var assetFile = await CheckAssetFileAsync(file); var command = new CreateAsset { File = assetFile }; var context = await CommandBus.PublishAsync(command); @@ -178,7 +185,7 @@ namespace Squidex.Controllers.Api.Assets [ApiCosts(1)] public async Task PutAssetContent(string app, Guid id, List file) { - var assetFile = GetAssetFile(file); + var assetFile = await CheckAssetFileAsync(file); var command = new UpdateAsset { File = assetFile, AssetId = id }; var context = await CommandBus.PublishAsync(command); @@ -232,7 +239,7 @@ namespace Squidex.Controllers.Api.Assets return NoContent(); } - private AssetFile GetAssetFile(IReadOnlyList file) + private async Task CheckAssetFileAsync(IReadOnlyList file) { if (file.Count != 1) { @@ -250,6 +257,17 @@ namespace Squidex.Controllers.Api.Assets throw new ValidationException("Cannot create asset.", error); } + var plan = appLimitProvider.GetPlanForApp(App); + + var currentSize = await assetStatsRepository.GetTotalSizeAsync(App.Id); + + if (plan.MaxAssetSize > 0 && plan.MaxAssetSize < currentSize + formFile.Length) + { + var error = new ValidationError("You have reached your max asset size."); + + throw new ValidationException("Cannot create asset.", error); + } + var assetFile = new AssetFile(formFile.FileName, formFile.ContentType, formFile.Length, formFile.OpenReadStream); return assetFile; diff --git a/src/Squidex/Controllers/Api/History/HistoryController.cs b/src/Squidex/Controllers/Api/History/HistoryController.cs index 05de112a4..8183c54f1 100644 --- a/src/Squidex/Controllers/Api/History/HistoryController.cs +++ b/src/Squidex/Controllers/Api/History/HistoryController.cs @@ -29,14 +29,11 @@ namespace Squidex.Controllers.Api.History [SwaggerTag("History")] public class HistoryController : ControllerBase { - private readonly IAppProvider appProvider; private readonly IHistoryEventRepository historyEventRepository; - public HistoryController(ICommandBus commandBus, IAppProvider appProvider, IHistoryEventRepository historyEventRepository) + public HistoryController(ICommandBus commandBus, IHistoryEventRepository historyEventRepository) : base(commandBus) { - this.appProvider = appProvider; - this.historyEventRepository = historyEventRepository; } @@ -55,16 +52,9 @@ namespace Squidex.Controllers.Api.History [ApiCosts(0.1)] public async Task GetHistory(string app, string channel) { - var entity = await appProvider.FindAppByNameAsync(app); - - if (entity == null) - { - return NotFound(); - } - - var schemas = await historyEventRepository.QueryByChannelAsync(entity.Id, channel, 100); + var entities = await historyEventRepository.QueryByChannelAsync(App.Id, channel, 100); - var response = schemas.Select(x => SimpleMapper.Map(x, new HistoryEventDto())).ToList(); + var response = entities.Select(x => SimpleMapper.Map(x, new HistoryEventDto())).ToList(); return Ok(response); } diff --git a/src/Squidex/Controllers/Api/Statistics/Models/CurrentCallsDto.cs b/src/Squidex/Controllers/Api/Statistics/Models/CurrentCallsDto.cs index 8fc66c5a7..7082bf5d8 100644 --- a/src/Squidex/Controllers/Api/Statistics/Models/CurrentCallsDto.cs +++ b/src/Squidex/Controllers/Api/Statistics/Models/CurrentCallsDto.cs @@ -14,5 +14,10 @@ namespace Squidex.Controllers.Api.Statistics.Models /// The number of calls. /// public long Count { get; set; } + + /// + /// The number of maximum allowed calls. + /// + public long MaxAllowed { get; set; } } } diff --git a/src/Squidex/Controllers/Api/Statistics/Models/CurrentStorageDto.cs b/src/Squidex/Controllers/Api/Statistics/Models/CurrentStorageDto.cs index 4991d2506..85f5e91d9 100644 --- a/src/Squidex/Controllers/Api/Statistics/Models/CurrentStorageDto.cs +++ b/src/Squidex/Controllers/Api/Statistics/Models/CurrentStorageDto.cs @@ -14,5 +14,10 @@ namespace Squidex.Controllers.Api.Statistics.Models /// The size in bytes. /// public long Size { get; set; } + + /// + /// The maximum allowed asset size. + /// + public long MaxAllowed { get; set; } } } diff --git a/src/Squidex/Controllers/Api/Statistics/UsagesController.cs b/src/Squidex/Controllers/Api/Statistics/UsagesController.cs index fa69f5743..0dd817aac 100644 --- a/src/Squidex/Controllers/Api/Statistics/UsagesController.cs +++ b/src/Squidex/Controllers/Api/Statistics/UsagesController.cs @@ -15,6 +15,7 @@ using Squidex.Controllers.Api.Statistics.Models; using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.UsageTracking; using Squidex.Pipeline; +using Squidex.Read.Apps.Services; using Squidex.Read.Assets.Repositories; namespace Squidex.Controllers.Api.Statistics @@ -28,13 +29,19 @@ namespace Squidex.Controllers.Api.Statistics public class UsagesController : ControllerBase { private readonly IUsageTracker usageTracker; + private readonly IAppLimitsProvider appLimitProvider; private readonly IAssetStatsRepository assetStatsRepository; - public UsagesController(ICommandBus commandBus, IUsageTracker usageTracker, IAssetStatsRepository assetStatsRepository) + public UsagesController( + ICommandBus commandBus, + IUsageTracker usageTracker, + IAppLimitsProvider appLimitProvider, + IAssetStatsRepository assetStatsRepository) : base(commandBus) { this.usageTracker = usageTracker; + this.appLimitProvider = appLimitProvider; this.assetStatsRepository = assetStatsRepository; } @@ -55,7 +62,9 @@ namespace Squidex.Controllers.Api.Statistics { var count = await usageTracker.GetMonthlyCalls(App.Id.ToString(), DateTime.Today); - return Ok(new CurrentCallsDto { Count = count }); + var plan = appLimitProvider.GetPlanForApp(App); + + return Ok(new CurrentCallsDto { Count = count, MaxAllowed = plan.MaxApiCalls }); } /// @@ -110,7 +119,9 @@ namespace Squidex.Controllers.Api.Statistics { var size = await assetStatsRepository.GetTotalSizeAsync(App.Id); - return Ok(new CurrentStorageDto { Size = size }); + var plan = appLimitProvider.GetPlanForApp(App); + + return Ok(new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize }); } /// diff --git a/src/Squidex/Pipeline/AppApiFilter.cs b/src/Squidex/Pipeline/AppApiFilter.cs index 4ea63a1dc..cebbb53c0 100644 --- a/src/Squidex/Pipeline/AppApiFilter.cs +++ b/src/Squidex/Pipeline/AppApiFilter.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Squidex.Core.Apps; using Squidex.Core.Identity; using Squidex.Infrastructure.Security; +using Squidex.Infrastructure.UsageTracking; using Squidex.Read.Apps; using Squidex.Read.Apps.Services; @@ -25,10 +26,15 @@ namespace Squidex.Pipeline public sealed class AppApiFilter : IAsyncAuthorizationFilter { private readonly IAppProvider appProvider; + private readonly IAppLimitsProvider appLimitProvider; + private readonly IUsageTracker usageTracker; - public AppApiFilter(IAppProvider appProvider) + public AppApiFilter(IAppProvider appProvider, IAppLimitsProvider appLimitProvider, IUsageTracker usageTracker) { this.appProvider = appProvider; + this.appLimitProvider = appLimitProvider; + + this.usageTracker = usageTracker; } public async Task OnAuthorizationAsync(AuthorizationFilterContext context) @@ -56,6 +62,16 @@ namespace Squidex.Pipeline context.Result = new NotFoundResult(); return; } + + var plan = appLimitProvider.GetPlanForApp(app); + + var usage = await usageTracker.GetMonthlyCalls(app.Id.ToString(), DateTime.Today); + + if (plan.MaxApiCalls >= 0 && (usage * 1.1) > plan.MaxApiCalls) + { + context.Result = new StatusCodeResult(429); + return; + } var defaultIdentity = context.HttpContext.User.Identities.First(); @@ -63,12 +79,15 @@ namespace Squidex.Pipeline { case PermissionLevel.Owner: defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppOwner)); + defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppDeveloper)); + defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppEditor)); break; case PermissionLevel.Editor: + defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppDeveloper)); defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppEditor)); break; case PermissionLevel.Developer: - defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppDeveloper)); + defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppEditor)); break; } diff --git a/src/Squidex/Pipeline/MustBeAppDeveloperAttribute.cs b/src/Squidex/Pipeline/MustBeAppDeveloperAttribute.cs index b61a5a379..f1acca4e2 100644 --- a/src/Squidex/Pipeline/MustBeAppDeveloperAttribute.cs +++ b/src/Squidex/Pipeline/MustBeAppDeveloperAttribute.cs @@ -15,7 +15,7 @@ namespace Squidex.Pipeline { public MustBeAppDeveloperAttribute() { - Roles = $"{SquidexRoles.AppOwner},{SquidexRoles.AppDeveloper}"; + Roles = SquidexRoles.AppDeveloper; } } } diff --git a/src/Squidex/Pipeline/MustBeAppEditorAttribute.cs b/src/Squidex/Pipeline/MustBeAppEditorAttribute.cs index 3496825e1..c36ddf3b3 100644 --- a/src/Squidex/Pipeline/MustBeAppEditorAttribute.cs +++ b/src/Squidex/Pipeline/MustBeAppEditorAttribute.cs @@ -15,7 +15,7 @@ namespace Squidex.Pipeline { public MustBeAppEditorAttribute() { - Roles = $"{SquidexRoles.AppOwner},{SquidexRoles.AppDeveloper},{SquidexRoles.AppEditor}"; + Roles = SquidexRoles.AppEditor; } } } diff --git a/src/Squidex/Pipeline/MustBeAppOwnerAttribute.cs b/src/Squidex/Pipeline/MustBeAppOwnerAttribute.cs index 881862816..8a4bc3315 100644 --- a/src/Squidex/Pipeline/MustBeAppOwnerAttribute.cs +++ b/src/Squidex/Pipeline/MustBeAppOwnerAttribute.cs @@ -15,7 +15,7 @@ namespace Squidex.Pipeline { public MustBeAppOwnerAttribute() { - Roles = $"{SquidexRoles.AppOwner}"; + Roles = SquidexRoles.AppOwner; } } } diff --git a/src/Squidex/Startup.cs b/src/Squidex/Startup.cs index 880838469..9c952c237 100644 --- a/src/Squidex/Startup.cs +++ b/src/Squidex/Startup.cs @@ -24,6 +24,7 @@ using Squidex.Config.Swagger; using Squidex.Config.Web; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log.Adapter; +using Squidex.Read.Apps.Services.Implementations; // ReSharper disable ConvertClosureToMethodGroup // ReSharper disable AccessToModifiedClosure @@ -75,6 +76,8 @@ namespace Squidex Configuration.GetSection("urls")); services.Configure( Configuration.GetSection("identity")); + services.Configure( + Configuration.GetSection("usage")); var builder = new ContainerBuilder(); builder.Populate(services); diff --git a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html index 3b0a4e802..1e72243a8 100644 --- a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html +++ b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html @@ -70,9 +70,10 @@
-
+
API calls this month
-
{{currentCalls}}
+
{{callsCurrent}}
+
Monthly limit: {{callsMax}}
@@ -85,9 +86,10 @@
-
+
Asset size today
-
{{currentStorage}}
+
{{assetsCurrent}}
+
Total limit: {{assetsMax}}
diff --git a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts index 64130ad61..d2a25d90b 100644 --- a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts +++ b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts @@ -51,8 +51,11 @@ export class DashboardPageComponent extends AppComponentBase implements OnInit, maintainAspectRatio: false }; - public currentStorage: string | null = null; - public currentCalls: string | null = null; + public assetsCurrent: string | null = null; + public assetsMax: string | null = null; + + public callsCurrent: string | null = null; + public callsMax: string | null = null; constructor(apps: AppsStoreService, notifications: NotificationService, private readonly auth: AuthService, @@ -69,26 +72,15 @@ export class DashboardPageComponent extends AppComponentBase implements OnInit, this.appName() .switchMap(app => this.usagesService.getTodayStorage(app)) .subscribe(dto => { - this.currentStorage = FileHelper.fileSize(dto.size); + this.assetsCurrent = FileHelper.fileSize(dto.size); + this.assetsMax = FileHelper.fileSize(dto.maxAllowed); }); this.appName() .switchMap(app => this.usagesService.getMonthCalls(app)) .subscribe(dto => { - let count = dto.count; - - if (count > 1000) { - count = count / 1000; - - if (count < 10) { - count = Math.round(count * 10) / 10; - } else { - count = Math.round(count); - } - this.currentCalls = count + 'k'; - } else { - this.currentCalls = count.toString(); - } + this.callsCurrent = formatCalls(dto.count); + this.callsMax = formatCalls(dto.maxAllowed); }); this.appName() @@ -170,6 +162,24 @@ export class DashboardPageComponent extends AppComponentBase implements OnInit, } } +function formatCalls(count: number): string { + if (count > 1000) { + count = count / 1000; + + if (count < 10) { + count = Math.round(count * 10) / 10; + } else { + count = Math.round(count); + } + + return count + 'k'; + } else if (count < 0) { + return undefined; + } else { + return count.toString(); + } +} + function createLabels(dtos: { date: DateTime }[]): string[] { const labels: string[] = []; diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html index 70032a0a9..14b1258ac 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html @@ -13,6 +13,10 @@
+
+ Your plan allows up to {{maxContributors}} contributors. +
+ @@ -56,7 +60,7 @@ - + diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss index 47c1bd80f..9a9bb11a2 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss @@ -11,4 +11,13 @@ font-style: italic; font-size: .8rem; } +} + +.contributors-limit { + margin: -$panel-padding; + margin-bottom: $panel-padding; + color: $color-dark-foreground; + background: $color-theme-green-dark; + border: 0; + padding: .5 * $panel-padding $panel-padding; } \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts index 8ee1fdfdf..2af4df749 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts @@ -64,6 +64,8 @@ export class ContributorsPageComponent extends AppComponentBase implements OnIni public currentUserId: string; + public maxContributors = -1; + public usersDataSource: UsersDataSource; public usersPermissions = [ 'Owner', @@ -71,6 +73,10 @@ export class ContributorsPageComponent extends AppComponentBase implements OnIni 'Editor' ]; + public get canAddContributor() { + return this.addContributorForm.valid && (this.maxContributors < -1 || this.appContributors.length < this.maxContributors); + } + public addContributorForm: FormGroup = this.formBuilder.group({ user: [null, @@ -99,8 +105,10 @@ export class ContributorsPageComponent extends AppComponentBase implements OnIni public load() { this.appNameOnce() .switchMap(app => this.appContributorsService.getContributors(app, this.version).retry(2)) - .subscribe(dtos => { - this.updateContributors(ImmutableArray.of(dtos)); + .subscribe(dto => { + this.updateContributors(ImmutableArray.of(dto.contributors)); + + this.maxContributors = dto.maxContributors; }, error => { this.notifyError(error); }); diff --git a/src/Squidex/app/shared/services/app-contributors.service.spec.ts b/src/Squidex/app/shared/services/app-contributors.service.spec.ts index 165344245..312146616 100644 --- a/src/Squidex/app/shared/services/app-contributors.service.spec.ts +++ b/src/Squidex/app/shared/services/app-contributors.service.spec.ts @@ -12,6 +12,7 @@ import { IMock, Mock, Times } from 'typemoq'; import { ApiUrlConfig, AppContributorDto, + AppContributorsDto, AppContributorsService, AuthService, Version @@ -32,32 +33,35 @@ describe('AppContributorsService', () => { .returns(() => Observable.of( new Response( new ResponseOptions({ - body: [ - { - contributorId: '123', - permission: 'Owner' - }, - { - contributorId: '456', - permission: 'Owner' - } - ] + body: { + contributors: [ + { + contributorId: '123', + permission: 'Owner' + }, + { + contributorId: '456', + permission: 'Owner' + } + ], + maxContributors: 100 + } }) ) )) .verifiable(Times.once()); - let contributors: AppContributorDto[] | null = null; + let contributors: AppContributorsDto | null = null; appContributorsService.getContributors('my-app', version).subscribe(result => { contributors = result; }).unsubscribe(); expect(contributors).toEqual( - [ - new AppContributorDto('123', 'Owner'), - new AppContributorDto('456', 'Owner') - ]); + new AppContributorsDto([ + new AppContributorDto('123', 'Owner'), + new AppContributorDto('456', 'Owner') + ], 100)); authService.verifyAll(); }); diff --git a/src/Squidex/app/shared/services/app-contributors.service.ts b/src/Squidex/app/shared/services/app-contributors.service.ts index 1bf5a3051..f2e23a3df 100644 --- a/src/Squidex/app/shared/services/app-contributors.service.ts +++ b/src/Squidex/app/shared/services/app-contributors.service.ts @@ -21,6 +21,14 @@ export class AppContributorDto { } } +export class AppContributorsDto { + constructor( + public readonly contributors: AppContributorDto[], + public readonly maxContributors: number + ) { + } +} + @Injectable() export class AppContributorsService { constructor( @@ -29,19 +37,21 @@ export class AppContributorsService { ) { } - public getContributors(appName: string, version?: Version): Observable { + public getContributors(appName: string, version?: Version): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/contributors`); return this.authService.authGet(url, version) .map(response => response.json()) .map(response => { - const items: any[] = response; + const items: any[] = response.contributors; - return items.map(item => { - return new AppContributorDto( - item.contributorId, - item.permission); - }); + return new AppContributorsDto( + items.map(item => { + return new AppContributorDto( + item.contributorId, + item.permission); + }), + response.maxContributors); }) .catchError('Failed to load contributors. Please reload.'); } diff --git a/src/Squidex/app/shared/services/usages.service.spec.ts b/src/Squidex/app/shared/services/usages.service.spec.ts index 580f20d63..4d908d5e9 100644 --- a/src/Squidex/app/shared/services/usages.service.spec.ts +++ b/src/Squidex/app/shared/services/usages.service.spec.ts @@ -71,7 +71,7 @@ describe('UsagesService', () => { .returns(() => Observable.of( new Response( new ResponseOptions({ - body: { count: 130 } + body: { count: 130, maxAllowed: 150 } }) ) )) @@ -83,7 +83,7 @@ describe('UsagesService', () => { usages = result; }).unsubscribe(); - expect(usages).toEqual(new CurrentCallsDto(130)); + expect(usages).toEqual(new CurrentCallsDto(130, 150)); authService.verifyAll(); }); @@ -130,7 +130,7 @@ describe('UsagesService', () => { .returns(() => Observable.of( new Response( new ResponseOptions({ - body: { size: 130 } + body: { size: 130, maxAllowed: 150 } }) ) )) @@ -142,7 +142,7 @@ describe('UsagesService', () => { usages = result; }).unsubscribe(); - expect(usages).toEqual(new CurrentStorageDto(130)); + expect(usages).toEqual(new CurrentStorageDto(130, 150)); authService.verifyAll(); }); diff --git a/src/Squidex/app/shared/services/usages.service.ts b/src/Squidex/app/shared/services/usages.service.ts index 0bc20fc29..dbb8120bd 100644 --- a/src/Squidex/app/shared/services/usages.service.ts +++ b/src/Squidex/app/shared/services/usages.service.ts @@ -33,14 +33,16 @@ export class StorageUsageDto { export class CurrentStorageDto { constructor( - public readonly size: number + public readonly size: number, + public readonly maxAllowed: number ) { } } export class CurrentCallsDto { constructor( - public readonly count: number + public readonly count: number, + public readonly maxAllowed: number ) { } } @@ -58,10 +60,19 @@ export class UsagesService { return this.authService.authGet(url) .map(response => response.json()) - .map(response => new CurrentCallsDto(response.count)) + .map(response => new CurrentCallsDto(response.count, response.maxAllowed)) .catchError('Failed to load monthly api calls. Please reload.'); } + public getTodayStorage(app: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/today`); + + return this.authService.authGet(url) + .map(response => response.json()) + .map(response => new CurrentStorageDto(response.size, response.maxAllowed)) + .catchError('Failed to load todays storage size. Please reload.'); + } + public getCallsUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable { const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/calls/${fromDate.toStringFormat('YYYY-MM-DD')}/${toDate.toStringFormat('YYYY-MM-DD')}`); @@ -80,15 +91,6 @@ export class UsagesService { .catchError('Failed to load calls usage. Please reload.'); } - public getTodayStorage(app: string): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/today`); - - return this.authService.authGet(url) - .map(response => response.json()) - .map(response => new CurrentStorageDto(response.size)) - .catchError('Failed to load todays storage size. Please reload.'); - } - public getStorageUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable { const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/${fromDate.toStringFormat('YYYY-MM-DD')}/${toDate.toStringFormat('YYYY-MM-DD')}`); diff --git a/tests/Squidex.Infrastructure.Tests/CQRS/Events/CompoundEventConsumerTests.cs b/tests/Squidex.Infrastructure.Tests/CQRS/Events/CompoundEventConsumerTests.cs index 38ce91330..14e70e829 100644 --- a/tests/Squidex.Infrastructure.Tests/CQRS/Events/CompoundEventConsumerTests.cs +++ b/tests/Squidex.Infrastructure.Tests/CQRS/Events/CompoundEventConsumerTests.cs @@ -57,8 +57,13 @@ namespace Squidex.Infrastructure.CQRS.Events [Fact] public async Task Should_clear_all_consumers() { - consumer1.Setup(x => x.ClearAsync()).Returns(TaskHelper.Done).Verifiable(); - consumer2.Setup(x => x.ClearAsync()).Returns(TaskHelper.Done).Verifiable(); + consumer1.Setup(x => x.ClearAsync()). + Returns(TaskHelper.Done) + .Verifiable(); + + consumer2.Setup(x => x.ClearAsync()) + .Returns(TaskHelper.Done) + .Verifiable(); var sut = new CompoundEventConsumer("consumer-name", consumer1.Object, consumer2.Object); @@ -73,8 +78,13 @@ namespace Squidex.Infrastructure.CQRS.Events { var @event = Envelope.Create(new MyEvent()); - consumer1.Setup(x => x.On(@event)).Returns(TaskHelper.Done).Verifiable(); - consumer2.Setup(x => x.On(@event)).Returns(TaskHelper.Done).Verifiable(); + consumer1.Setup(x => x.On(@event)) + .Returns(TaskHelper.Done) + .Verifiable(); + + consumer2.Setup(x => x.On(@event)) + .Returns(TaskHelper.Done) + .Verifiable(); var sut = new CompoundEventConsumer("consumer-name", consumer1.Object, consumer2.Object); diff --git a/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs b/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs index 17f1f2918..26e72d4d1 100644 --- a/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs +++ b/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs @@ -121,9 +121,17 @@ namespace Squidex.Infrastructure.UsageTracking { var today = DateTime.Today; - usageStore.Setup(x => x.TrackUsagesAsync(today, "key1", 1.0, 1000)).Returns(TaskHelper.Done).Verifiable(); - usageStore.Setup(x => x.TrackUsagesAsync(today, "key2", 1.5, 5000)).Returns(TaskHelper.Done).Verifiable(); - usageStore.Setup(x => x.TrackUsagesAsync(today, "key3", 0.9, 15000)).Returns(TaskHelper.Done).Verifiable(); + usageStore.Setup(x => x.TrackUsagesAsync(today, "key1", 1.0, 1000)) + .Returns(TaskHelper.Done) + .Verifiable(); + + usageStore.Setup(x => x.TrackUsagesAsync(today, "key2", 1.5, 5000)) + .Returns(TaskHelper.Done) + .Verifiable(); + + usageStore.Setup(x => x.TrackUsagesAsync(today, "key3", 0.9, 15000)) + .Returns(TaskHelper.Done) + .Verifiable(); await sut.TrackAsync("key1", 1, 1000); diff --git a/tests/Squidex.Read.Tests/Apps/ConfigAppLimitsProviderTests.cs b/tests/Squidex.Read.Tests/Apps/ConfigAppLimitsProviderTests.cs new file mode 100644 index 000000000..6c7ecbf74 --- /dev/null +++ b/tests/Squidex.Read.Tests/Apps/ConfigAppLimitsProviderTests.cs @@ -0,0 +1,102 @@ +// ========================================================================== +// ConfigAppLimitsProviderTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using FluentAssertions; +using Moq; +using Squidex.Read.Apps.Services.Implementations; +using Xunit; + +namespace Squidex.Read.Apps +{ + public class ConfigAppLimitsProviderTests + { + private static readonly ConfigAppLimitsPlan[] Plans = + { + new ConfigAppLimitsPlan + { + Name = "Basic", + MaxApiCalls = 150000, + MaxAssetSize = 1024 * 1024 * 2, + MaxContributors = 5 + }, + new ConfigAppLimitsPlan + { + Name = "Free", + MaxApiCalls = 50000, + MaxAssetSize = 1024 * 1024 * 10, + MaxContributors = 2 + } + }; + + [Fact] + public void Should_return_plans() + { + var sut = new ConfigAppLimitsProvider(Plans); + + Plans.OrderBy(x => x.MaxApiCalls).ShouldBeEquivalentTo(sut.GetAvailablePlans()); + } + + [Fact] + public void Should_return_infinite_if_nothing_configured() + { + var sut = new ConfigAppLimitsProvider(Enumerable.Empty()); + + var plan = sut.GetPlanForApp(CreateApp(0)); + + plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan + { + Name = "Infinite", + MaxApiCalls = -1, + MaxAssetSize = -1, + MaxContributors = -1 + }); + } + + [Fact] + public void Should_return_fitting_app_plan() + { + var sut = new ConfigAppLimitsProvider(Plans); + + var plan = sut.GetPlanForApp(CreateApp(1)); + + plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan + { + Name = "Basic", + MaxApiCalls = 150000, + MaxAssetSize = 1024 * 1024 * 2, + MaxContributors = 5 + }); + } + + [Fact] + public void Should_smallest_plan_if_none_fits() + { + var sut = new ConfigAppLimitsProvider(Plans); + + var plan = sut.GetPlanForApp(CreateApp(4)); + + plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan + { + Name = "Free", + MaxApiCalls = 50000, + MaxAssetSize = 1024 * 1024 * 10, + MaxContributors = 2 + }); + } + + private static IAppEntity CreateApp(int plan) + { + var app = new Mock(); + + app.Setup(x => x.PlanId).Returns(plan); + + return app.Object; + } + } +} diff --git a/tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs b/tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs index a939c7e9f..fdfacd2b3 100644 --- a/tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs +++ b/tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs @@ -14,6 +14,8 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.CQRS.Commands; using Squidex.Read.Apps; using Squidex.Read.Apps.Repositories; +using Squidex.Read.Apps.Services; +using Squidex.Read.Apps.Services.Implementations; using Squidex.Read.Users; using Squidex.Read.Users.Repositories; using Squidex.Write.Apps.Commands; @@ -29,6 +31,7 @@ namespace Squidex.Write.Apps { private readonly Mock keyGenerator = new Mock(); private readonly Mock appRepository = new Mock(); + private readonly Mock appLimitsProvider = new Mock(); private readonly Mock userRepository = new Mock(); private readonly AppCommandHandler sut; private readonly AppDomainObject app; @@ -39,9 +42,11 @@ namespace Squidex.Write.Apps public AppCommandHandlerTests() { + appLimitsProvider.Setup(x => x.GetPlan(0)).Returns(new ConfigAppLimitsPlan { MaxContributors = 2 }); + app = new AppDomainObject(AppId, -1); - sut = new AppCommandHandler(Handler, appRepository.Object, userRepository.Object, keyGenerator.Object); + sut = new AppCommandHandler(Handler, appRepository.Object, appLimitsProvider.Object, userRepository.Object, keyGenerator.Object); } [Fact] @@ -93,12 +98,29 @@ namespace Squidex.Write.Apps }, false); } + [Fact] + public async Task AssignContributor_throw_if_reached_max_contributor_size() + { + CreateApp() + .AssignContributor(CreateCommand(new AssignContributor { ContributorId = "1" })) + .AssignContributor(CreateCommand(new AssignContributor { ContributorId = "2" })); + + var context = CreateContextForCommand(new AssignContributor { ContributorId = contributorId }); + + userRepository.Setup(x => x.FindUserByIdAsync(It.IsAny())).Returns(Task.FromResult(new Mock().Object)); + + await TestUpdate(app, async _ => + { + await Assert.ThrowsAsync(() => sut.HandleAsync(context)); + }, false); + } + [Fact] public async Task AssignContributor_should_throw_if_null_user_not_found() { CreateApp(); - var context = CreateContextForCommand(new AssignContributor { ContributorId = null }); + var context = CreateContextForCommand(new AssignContributor { ContributorId = contributorId }); userRepository.Setup(x => x.FindUserByIdAsync(contributorId)).Returns(Task.FromResult(null)); diff --git a/tests/Squidex.Write.Tests/Assets/AssetCommandHandlerTests.cs b/tests/Squidex.Write.Tests/Assets/AssetCommandHandlerTests.cs index 2ae16313a..5d06a6298 100644 --- a/tests/Squidex.Write.Tests/Assets/AssetCommandHandlerTests.cs +++ b/tests/Squidex.Write.Tests/Assets/AssetCommandHandlerTests.cs @@ -112,12 +112,16 @@ namespace Squidex.Write.Assets private void SetupImageInfo() { - assetThumbnailGenerator.Setup(x => x.GetImageInfoAsync(stream)).Returns(Task.FromResult(image)).Verifiable(); + assetThumbnailGenerator + .Setup(x => x.GetImageInfoAsync(stream)).Returns(Task.FromResult(image)) + .Verifiable(); } private void SetupStore(long version) { - assetStore.Setup(x => x.UploadAsync(assetId, version, null, stream)).Returns(TaskHelper.Done).Verifiable(); + assetStore + .Setup(x => x.UploadAsync(assetId, version, null, stream)).Returns(TaskHelper.Done) + .Verifiable(); } } }