diff --git a/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md b/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md index e53f633ae9..d3a851247f 100644 --- a/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md +++ b/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md @@ -1,4 +1,4 @@ -# Operation Rate Limiting in ABP Framework +# Operation Rate Limiting in ABP Almost every user-facing system eventually runs into the same problem: **some operations cannot be allowed to run without limits**. @@ -24,15 +24,9 @@ Real-world requirements tend to look like this: The pattern is clear: the identity being throttled is a **business identity** — a user, a phone number, a resource ID — not an IP address. And the action being throttled is a **business operation**, not an HTTP request. -ABP Framework's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what. +ABP's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what. -Add the package to your project: - -```bash -abp add-package Volo.Abp.OperationRateLimiting -``` - -> Operation Rate Limiting is available starting from **ABP Framework 10.3**. See the [pull request](https://github.com/abpframework/abp/pull/25024) for details. +This module is used by the Account (Pro) modules internally and comes pre-installed in the latest startup templates. You must have an [ABP Team or a higher license](https://abp.io/pricing) to use this module. ## Defining a Policy @@ -66,7 +60,7 @@ public class SmsAppService : ApplicationService _rateLimitChecker = rateLimitChecker; } - public async Task SendCodeAsync(string phoneNumber) + public virtual async Task SendCodeAsync(string phoneNumber) { await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber); @@ -77,6 +71,74 @@ public class SmsAppService : ApplicationService `CheckAsync` checks the current usage against the limit and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is already exceeded. If the check passes, it then increments the counter and proceeds. ABP's exception pipeline catches this automatically and returns a standard error response. Put `CheckAsync` first — the rate limit check is the gate, and everything else only runs if it passes. +## Declarative Usage with `[OperationRateLimiting]` + +The explicit `CheckAsync` approach is useful when you need fine-grained control — for example, when you want to check the limit conditionally, or when the parameter value comes from somewhere other than a method argument. But for the common case where you simply want to enforce a policy on every invocation of a specific method, there's a cleaner way: the `[OperationRateLimiting]` attribute. + +```csharp +public class SmsAppService : ApplicationService +{ + [OperationRateLimiting("SendSmsCode")] + public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber) + { + // Rate limit is enforced automatically — no manual CheckAsync needed. + await _smsSender.SendAsync(phoneNumber, GenerateCode()); + } +} +``` + +The attribute works on both **Application Service methods** (via ABP's interceptor) and **MVC Controller actions** (via an action filter). No manual injection of `IOperationRateLimitingChecker` required. + +### Providing the Partition Key + +When using the attribute, the partition key is resolved from the method's parameters automatically: + +- Mark a parameter with `[RateLimitingParameter]` to use its `ToString()` value as the key — this is the most common case when the key is a single primitive like a phone number or email. +- Have your input DTO implement `IHasOperationRateLimitingParameter` and provide a `GetPartitionParameter()` method — useful when the key is a property buried inside a complex input object. + +```csharp +public class SendSmsCodeInput : IHasOperationRateLimitingParameter +{ + public string PhoneNumber { get; set; } + public string Language { get; set; } + + public string? GetPartitionParameter() => PhoneNumber; +} + +[OperationRateLimiting("SendSmsCode")] +public virtual async Task SendCodeAsync(SendSmsCodeInput input) +{ + // input.GetPartitionParameter() = input.PhoneNumber is used as the partition key. +} +``` + +If neither is provided, `Parameter` is `null` — which is perfectly valid for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, or similar partition types that don't rely on an explicit value. + +```csharp +// Policy uses PartitionByCurrentUser — no partition key needed. +[OperationRateLimiting("GenerateReport")] +public virtual async Task GenerateMonthlyReportAsync() +{ + // Rate limit is checked per current user, automatically. +} +``` + +> The resolution order is: `[RateLimitingParameter]` first, then `IHasOperationRateLimitingParameter`, then `null`. If the method has parameters but none is resolved, a warning is logged to help you catch the misconfiguration early. + +You can also place `[OperationRateLimiting]` on the class itself to apply the policy to all public methods: + +```csharp +[OperationRateLimiting("MyServiceLimit")] +public class MyAppService : ApplicationService +{ + public virtual async Task MethodAAsync([RateLimitingParameter] string key) { ... } + + public virtual async Task MethodBAsync([RateLimitingParameter] string key) { ... } +} +``` + +A method-level attribute always takes precedence over the class-level one. + ## Choosing a Partition Type The partition type controls **how counters are isolated from each other** — it's the most important decision when setting up a policy, because it determines *what dimension you're counting across*. @@ -87,7 +149,7 @@ Getting this wrong can make your rate limiting completely ineffective. Using `Pa - **`PartitionByCurrentUser`** — uses the authenticated user's ID, with no value to pass. Perfect for "each user gets N per day" scenarios where user identity is all you need. - **`PartitionByClientIp`** — uses the client's IP address. Don't rely on this alone — it's too easy to rotate. Use it as a secondary layer alongside another partition type, as in the login example below. - **`PartitionByEmail`** and **`PartitionByPhoneNumber`** — designed for pre-authentication flows where the user isn't logged in yet. They prefer the `Parameter` value you explicitly pass, and fall back to the current user's email or phone number if none is provided. -- **`PartitionBy`** — a custom async delegate that can produce any partition key you need. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string. +- **`PartitionBy`** — a named custom resolver that can produce any partition key you need. Register a resolver function under a unique name via `options.AddPartitionKeyResolver("MyResolver", ctx => ...)`, then reference it by name: `.PartitionBy("MyResolver")`. You can also register and reference in one step: `.PartitionBy("MyResolver", ctx => ...)`. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string. Because the resolver is stored by name (not as an anonymous delegate), it can be serialized and managed from a UI or database. > The rule of thumb: partition by the identity of whoever's behavior you're trying to limit. @@ -243,10 +305,10 @@ public override void ConfigureServices(ServiceConfigurationContext context) ## Summary -ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Call `CheckAsync` wherever you need it. Counter storage, distributed locking, and exception handling are all taken care of. +ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Then either call `CheckAsync` explicitly, or just add `[OperationRateLimiting]` to your method and let the framework handle the rest. Counter storage, distributed locking, and exception handling are all taken care of. ## References -- [Operation Rate Limiting](https://abp.io/docs/latest/framework/infrastructure/operation-rate-limiting) +- [Operation Rate Limiting (Pro)](https://abp.io/docs/latest/modules/operation-rate-limiting) - [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) - [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 0cbdb312ac..b0a76124b4 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -807,10 +807,6 @@ "text": "Object to Object Mapping", "path": "framework/infrastructure/object-to-object-mapping.md" }, - { - "text": "Operation Rate Limiting", - "path": "framework/infrastructure/operation-rate-limiting.md" - }, { "text": "Settings", "path": "framework/infrastructure/settings.md" @@ -2577,6 +2573,10 @@ "text": "Language Management (Pro)", "path": "modules/language-management.md" }, + { + "text": "Operation Rate Limiting (Pro)", + "path": "modules/operation-rate-limiting.md" + }, { "text": "OpenIddict", "isLazyExpandable": true, diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/modules/operation-rate-limiting.md similarity index 71% rename from docs/en/framework/infrastructure/operation-rate-limiting.md rename to docs/en/modules/operation-rate-limiting.md index cd693828e1..f285793695 100644 --- a/docs/en/framework/infrastructure/operation-rate-limiting.md +++ b/docs/en/modules/operation-rate-limiting.md @@ -1,11 +1,13 @@ ````json //[doc-seo] { - "Description": "Learn how to use the Operation Rate Limiting module in ABP Framework to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks." + "Description": "Learn how to use the Operation Rate Limiting module (Pro) in ABP to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks." } ```` -# Operation Rate Limiting +# Operation Rate Limiting Module (Pro) + +> You must have an [ABP Team or a higher license](https://abp.io/pricing) to use this module. ABP provides an operation rate limiting system that allows you to control the frequency of specific operations in your application. You may need operation rate limiting for several reasons: @@ -15,15 +17,9 @@ ABP provides an operation rate limiting system that allows you to control the fr > This is not for [ASP.NET Core's built-in rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) which works at the HTTP request pipeline level. This module works at the **application/domain code level** and is called explicitly from your services. See the [Combining with ASP.NET Core Rate Limiting](#combining-with-aspnet-core-rate-limiting) section for a comparison. -## Installation - -You can open a command-line terminal and type the following command to install the [Volo.Abp.OperationRateLimiting](https://www.nuget.org/packages/Volo.Abp.OperationRateLimiting) package into your project: - -````bash -abp add-package Volo.Abp.OperationRateLimiting -```` +## How to Install -> If you haven't done it yet, you first need to install the [ABP CLI](../../../cli). +This module is used by the [Account (Pro)](account-pro.md) module internally and comes pre-installed in the latest [startup templates](../solution-templates). So, no need to manually install it. ## Quick Start @@ -31,7 +27,7 @@ This section shows the basic usage of the operation rate limiting system with a ### Defining a Policy -First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md): +First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../framework/architecture/modularity/basics.md): ````csharp Configure(options => @@ -62,7 +58,7 @@ public class SmsAppService : ApplicationService _rateLimitChecker = rateLimitChecker; } - public async Task SendCodeAsync(string phoneNumber) + public virtual async Task SendCodeAsync(string phoneNumber) { await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber); @@ -78,9 +74,120 @@ public class SmsAppService : ApplicationService That's the basic usage. The following sections explain each concept in detail. +## Declarative Usage (Attribute) + +Instead of injecting `IOperationRateLimitingChecker` manually, you can use the `[OperationRateLimiting]` attribute to enforce a policy declaratively on Application Service methods or MVC Controller actions. + +> **Application Services** are handled by the ABP interceptor (built into the Domain layer). +> **MVC Controllers** are handled by `AbpOperationRateLimitingActionFilter`, which is automatically registered when you reference the `Volo.Abp.OperationRateLimiting.AspNetCore` package. + +### Applying to an Application Service + +````csharp +public class SmsAppService : ApplicationService +{ + [OperationRateLimiting("SendSmsCode")] + public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber) + { + // Rate limit is checked automatically before this line executes. + await _smsSender.SendAsync(phoneNumber, GenerateCode()); + } +} +```` + +### Applying to an MVC Controller + +````csharp +[Route("api/account")] +public class AccountController : AbpController +{ + [HttpPost("send-sms-code")] + [OperationRateLimiting("SendSmsCode")] + public async Task SendSmsCodeAsync([RateLimitingParameter] string phoneNumber) + { + // Rate limit is checked automatically before this line executes. + await _smsSender.SendAsync(phoneNumber, GenerateCode()); + return Ok(); + } +} +```` + +### Resolving the Parameter Value + +The `[OperationRateLimiting]` attribute resolves `OperationRateLimitingContext.Parameter` automatically using the following priority order: + +1. **`[RateLimitingParameter]`** — a method parameter marked with this attribute. Its `ToString()` value is used as the partition key. +2. **`IHasOperationRateLimitingParameter`** — a method parameter whose type implements this interface. The value returned by `GetPartitionParameter()` is used as the partition key. +3. **`null`** — no parameter is resolved; suitable for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, etc. + +#### Using `[RateLimitingParameter]` + +Mark a single parameter to use its value as the partition key: + +````csharp +[OperationRateLimiting("SendSmsCode")] +public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber) +{ + // partition key = phoneNumber +} +```` + +#### Using `IHasOperationRateLimitingParameter` + +Implement the interface on an input DTO when the partition key is a property of the DTO: + +````csharp +public class SendSmsCodeInput : IHasOperationRateLimitingParameter +{ + public string PhoneNumber { get; set; } + public string Language { get; set; } + + public string? GetPartitionParameter() => PhoneNumber; +} +```` + +````csharp +[OperationRateLimiting("SendSmsCode")] +public virtual async Task SendCodeAsync(SendSmsCodeInput input) +{ + // partition key = input.GetPartitionParameter() = input.PhoneNumber +} +```` + +#### No Partition Parameter + +If no parameter is marked and no DTO implements the interface, the policy is checked without a `Parameter` value. This is appropriate for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, or `PartitionByCurrentTenant`: + +````csharp +// Policy uses PartitionByCurrentUser — no explicit parameter needed. +[OperationRateLimiting("GenerateReport")] +public virtual async Task GenerateMonthlyReportAsync() +{ + // Rate limit is checked per current user automatically. +} +```` + +> If the method has parameters but none is resolved, a **warning log** is emitted to help you catch misconfigured usages early. + +### Applying to a Class + +You can also place `[OperationRateLimiting]` on the class to apply it to **all public methods** of that class: + +````csharp +[OperationRateLimiting("MyServiceLimit")] +public class MyAppService : ApplicationService +{ + public virtual async Task MethodAAsync([RateLimitingParameter] string key) { ... } + + public virtual async Task MethodBAsync([RateLimitingParameter] string key) { ... } +} +```` + +> A method-level attribute takes precedence over the class-level attribute. + ## Defining Policies -Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy. +Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../framework/architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy. ### Single-Rule Policies @@ -267,14 +374,59 @@ Works the same way as `PartitionByEmail`: resolves from `context.Parameter` firs ### Custom Partition (PartitionBy) -You can provide a custom async function to generate the partition key. The async signature allows you to perform database queries or other I/O operations: +You can register a named custom resolver to generate the partition key. The resolver is an async function, so you can perform database queries or other I/O operations. Because the resolver is stored by name (not as an anonymous delegate), it can be serialized and managed from a UI or database. + +**Step 1 — Register the resolver by name:** + +````csharp +Configure(options => +{ + options.AddPartitionKeyResolver("ByDevice", ctx => + Task.FromResult($"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}")); +}); +```` + +**Step 2 — Reference it in a policy:** ````csharp policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) - .PartitionBy(ctx => Task.FromResult( - $"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}")); + .PartitionBy("ByDevice"); ```` +You can also register and reference in one step (inline): + +````csharp +policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionBy("ByDevice", ctx => + Task.FromResult($"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}")); +```` + +> If you call `PartitionBy("name")` with a resolver name that hasn't been registered, an exception is thrown at configuration time (not at runtime), so typos are caught early. + +To replace an existing resolver (e.g., in a downstream module), use `ReplacePartitionKeyResolver`: + +````csharp +options.ReplacePartitionKeyResolver("ByDevice", ctx => + Task.FromResult($"v2:{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}")); +```` + +### Named Rules (WithName) + +By default, a rule's store key is derived from its `Duration`, `MaxCount`, and `PartitionType`. This means that if you change a rule's parameters (e.g., increase `maxCount` from 5 to 10), the counter resets because the key changes. + +To keep a stable key across parameter changes, give the rule a name: + +````csharp +policy.AddRule(rule => rule + .WithName("HourlyLimit") + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionByCurrentUser()); +```` + +When a name is set, it is used as the store key instead of the content-based descriptor. This is particularly useful when rules are managed from a database or UI — changing the `maxCount` or `duration` will not reset existing counters. + +> Rule names must be unique within a policy. Duplicate names cause an exception at build time. + ## Multi-Tenancy By default, partition keys do not include tenant information — for partition types like `PartitionByParameter`, `PartitionByCurrentUser`, `PartitionByClientIp`, etc., counters are shared across tenants unless you call `WithMultiTenancy()`. Note that `PartitionByCurrentTenant()` is inherently per-tenant since the partition key is the tenant ID itself, and `PartitionByClientIp()` is typically kept global since the same IP should share a counter regardless of tenant. @@ -506,7 +658,7 @@ This module and ASP.NET Core's built-in [rate limiting middleware](https://learn |---|---|---| | **Level** | HTTP request pipeline | Application/domain code | | **Scope** | All incoming requests | Specific business operations | -| **Usage** | Middleware (automatic) | Explicit `CheckAsync` calls | +| **Usage** | Middleware (automatic) | `[OperationRateLimiting]` attribute or explicit `CheckAsync` calls | | **Typical use** | API throttling, DDoS protection | Business logic limits (SMS, reports) | A common pattern is to use ASP.NET Core middleware for broad API protection and this module for fine-grained business operation limits. @@ -539,7 +691,7 @@ public class MyCustomStore : IOperationRateLimitingStore, ITransientDependency } ```` -ABP's [dependency injection](../../fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one. +ABP's [dependency injection](../framework/fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one. ### Custom Rule @@ -557,8 +709,33 @@ Replace `IOperationRateLimitingFormatter` to customize how time durations are di Replace `IOperationRateLimitingPolicyProvider` to load policies from a database or external configuration source instead of the in-memory options. +When loading pre-built policies from an external source, use the `AddPolicy` overload that accepts an `OperationRateLimitingPolicy` object directly (bypassing the builder): + +````csharp +options.AddPolicy(new OperationRateLimitingPolicy +{ + Name = "DynamicPolicy", + Rules = + [ + new OperationRateLimitingRuleDefinition + { + Name = "HourlyLimit", + Duration = TimeSpan.FromHours(1), + MaxCount = 100, + PartitionType = OperationRateLimitingPartitionType.CurrentUser + } + ] +}); +```` + +To remove a policy (e.g., when it is deleted from the database), use `RemovePolicy`: + +````csharp +options.RemovePolicy("DynamicPolicy"); +```` + ## See Also * [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) -* [Distributed Caching](../fundamentals/caching.md) -* [Exception Handling](../fundamentals/exception-handling.md) +* [Distributed Caching](../framework/fundamentals/caching.md) +* [Exception Handling](../framework/fundamentals/exception-handling.md) diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx index 1e36f1d212..1302600c09 100644 --- a/framework/Volo.Abp.slnx +++ b/framework/Volo.Abp.slnx @@ -169,7 +169,6 @@ - @@ -257,6 +256,5 @@ - diff --git a/framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml b/framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml deleted file mode 100644 index 7e9f94ead6..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj b/framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj deleted file mode 100644 index ffac7ef34e..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0 - enable - Nullable - Volo.Abp.OperationRateLimiting - Volo.Abp.OperationRateLimiting - $(AssetTargetFallback);portable-net45+win8+wp8+wpa81; - false - false - false - - - - - - - - - - - - - - - - - diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs deleted file mode 100644 index ba2bb5c189..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Volo.Abp.OperationRateLimiting; - -public static class AbpOperationRateLimitingErrorCodes -{ - /// - /// Default error code for rate limit exceeded (with a retry-after window). - /// - public const string ExceedLimit = "Volo.Abp.OperationRateLimiting:010001"; - - /// - /// Error code for ban policy (maxCount: 0) where requests are permanently denied. - /// - public const string ExceedLimitPermanently = "Volo.Abp.OperationRateLimiting:010002"; -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs deleted file mode 100644 index 0462a285a5..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Volo.Abp.AspNetCore; -using Volo.Abp.Caching; -using Volo.Abp.DistributedLocking; -using Volo.Abp.Localization; -using Volo.Abp.Localization.ExceptionHandling; -using Volo.Abp.Modularity; -using Volo.Abp.Security; -using Volo.Abp.VirtualFileSystem; - -namespace Volo.Abp.OperationRateLimiting; - -[DependsOn( - typeof(AbpCachingModule), - typeof(AbpLocalizationModule), - typeof(AbpSecurityModule), - typeof(AbpAspNetCoreAbstractionsModule), - typeof(AbpDistributedLockingAbstractionsModule) -)] -public class AbpOperationRateLimitingModule : AbpModule -{ - public override void ConfigureServices(ServiceConfigurationContext context) - { - Configure(options => - { - options.FileSets.AddEmbedded(); - }); - - Configure(options => - { - options.Resources - .Add("en") - .AddVirtualJson("/Volo/Abp/OperationRateLimiting/Localization"); - }); - - Configure(options => - { - options.MapCodeNamespace( - "Volo.Abp.OperationRateLimiting", - typeof(AbpOperationRateLimitingResource)); - }); - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs deleted file mode 100644 index 9de7a97412..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Volo.Abp.OperationRateLimiting; - -public class AbpOperationRateLimitingOptions -{ - public bool IsEnabled { get; set; } = true; - - public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5); - - public Dictionary Policies { get; } = new(); - - public AbpOperationRateLimitingOptions AddPolicy(string name, Action configure) - { - Check.NotNullOrWhiteSpace(name, nameof(name)); - Check.NotNull(configure, nameof(configure)); - - var builder = new OperationRateLimitingPolicyBuilder(name); - configure(builder); - Policies[name] = builder.Build(); - return this; - } - - /// - /// Configures an existing rate limiting policy by name. - /// The builder is pre-populated with the existing policy's rules and error code, - /// so you can add, clear, or replace rules while keeping what you don't change. - /// Throws if the policy is not found. - /// - public AbpOperationRateLimitingOptions ConfigurePolicy(string name, Action configure) - { - Check.NotNullOrWhiteSpace(name, nameof(name)); - Check.NotNull(configure, nameof(configure)); - - if (!Policies.TryGetValue(name, out var existingPolicy)) - { - throw new AbpException( - $"Could not find operation rate limiting policy: '{name}'. " + - "Make sure the policy is defined with AddPolicy() before calling ConfigurePolicy()."); - } - - var builder = OperationRateLimitingPolicyBuilder.FromPolicy(existingPolicy); - configure(builder); - Policies[name] = builder.Build(); - return this; - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs deleted file mode 100644 index e4f93d97d0..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Volo.Abp.Localization; - -namespace Volo.Abp.OperationRateLimiting; - -[LocalizationResourceName("AbpOperationRateLimiting")] -public class AbpOperationRateLimitingResource -{ -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs deleted file mode 100644 index 2220c241e8..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Threading.Tasks; - -namespace Volo.Abp.OperationRateLimiting; - -public interface IOperationRateLimitingChecker -{ - Task CheckAsync(string policyName, OperationRateLimitingContext? context = null); - - Task IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null); - - Task GetStatusAsync(string policyName, OperationRateLimitingContext? context = null); - - Task ResetAsync(string policyName, OperationRateLimitingContext? context = null); -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs deleted file mode 100644 index 095fa6cbf6..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Volo.Abp.AspNetCore.WebClientInfo; -using Volo.Abp.DependencyInjection; -using Volo.Abp.MultiTenancy; -using Volo.Abp.Users; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITransientDependency -{ - protected AbpOperationRateLimitingOptions Options { get; } - protected IOperationRateLimitingPolicyProvider PolicyProvider { get; } - protected IServiceProvider ServiceProvider { get; } - protected IOperationRateLimitingStore Store { get; } - protected ICurrentUser CurrentUser { get; } - protected ICurrentTenant CurrentTenant { get; } - protected IWebClientInfoProvider WebClientInfoProvider { get; } - - public OperationRateLimitingChecker( - IOptions options, - IOperationRateLimitingPolicyProvider policyProvider, - IServiceProvider serviceProvider, - IOperationRateLimitingStore store, - ICurrentUser currentUser, - ICurrentTenant currentTenant, - IWebClientInfoProvider webClientInfoProvider) - { - Options = options.Value; - PolicyProvider = policyProvider; - ServiceProvider = serviceProvider; - Store = store; - CurrentUser = currentUser; - CurrentTenant = currentTenant; - WebClientInfoProvider = webClientInfoProvider; - } - - public virtual async Task CheckAsync(string policyName, OperationRateLimitingContext? context = null) - { - if (!Options.IsEnabled) - { - return; - } - - context = EnsureContext(context); - var policy = await PolicyProvider.GetAsync(policyName); - var rules = CreateRules(policy); - - // Phase 1: Check ALL rules without incrementing to get complete status. - // Do not exit early: a later rule may have a larger RetryAfter that the caller needs to know about. - var checkResults = new List(); - foreach (var rule in rules) - { - checkResults.Add(await rule.CheckAsync(context)); - } - - if (checkResults.Any(r => !r.IsAllowed)) - { - // Throw without incrementing any counter; RetryAfter is the max across all blocking rules. - var aggregatedResult = AggregateResults(checkResults, policy); - ThrowRateLimitException(policy, aggregatedResult, context); - } - - // Phase 2: All rules passed in Phase 1 - now increment counters. - // Guard against concurrent races where another request consumed the last quota - // between Phase 1 and Phase 2. - // Once any rule fails during increment, stop incrementing subsequent rules - // to minimize wasted quota. Remaining rules use read-only check instead. - var incrementResults = new List(); - var phase2Failed = false; - foreach (var rule in rules) - { - if (phase2Failed) - { - incrementResults.Add(await rule.CheckAsync(context)); - } - else - { - var result = await rule.AcquireAsync(context); - incrementResults.Add(result); - if (!result.IsAllowed) - { - phase2Failed = true; - } - } - } - - if (phase2Failed) - { - var aggregatedResult = AggregateResults(incrementResults, policy); - ThrowRateLimitException(policy, aggregatedResult, context); - } - } - - public virtual async Task IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null) - { - if (!Options.IsEnabled) - { - return true; - } - - context = EnsureContext(context); - var policy = await PolicyProvider.GetAsync(policyName); - var rules = CreateRules(policy); - - foreach (var rule in rules) - { - var result = await rule.CheckAsync(context); - if (!result.IsAllowed) - { - return false; - } - } - - return true; - } - - public virtual async Task GetStatusAsync(string policyName, OperationRateLimitingContext? context = null) - { - if (!Options.IsEnabled) - { - return new OperationRateLimitingResult - { - IsAllowed = true, - RemainingCount = int.MaxValue, - MaxCount = int.MaxValue, - CurrentCount = 0 - }; - } - - context = EnsureContext(context); - var policy = await PolicyProvider.GetAsync(policyName); - var rules = CreateRules(policy); - var ruleResults = new List(); - - foreach (var rule in rules) - { - ruleResults.Add(await rule.CheckAsync(context)); - } - - return AggregateResults(ruleResults, policy); - } - - public virtual async Task ResetAsync(string policyName, OperationRateLimitingContext? context = null) - { - if (!Options.IsEnabled) - { - return; - } - - context = EnsureContext(context); - var policy = await PolicyProvider.GetAsync(policyName); - var rules = CreateRules(policy); - - foreach (var rule in rules) - { - await rule.ResetAsync(context); - } - } - - protected virtual OperationRateLimitingContext EnsureContext(OperationRateLimitingContext? context) - { - context ??= new OperationRateLimitingContext(); - context.ServiceProvider = ServiceProvider; - return context; - } - - protected virtual List CreateRules(OperationRateLimitingPolicy policy) - { - var rules = new List(); - - foreach (var ruleDefinition in policy.Rules) - { - rules.Add(new FixedWindowOperationRateLimitingRule( - policy.Name, - ruleDefinition, - Store, - CurrentUser, - CurrentTenant, - WebClientInfoProvider)); - } - - foreach (var customRuleType in policy.CustomRuleTypes) - { - rules.Add((IOperationRateLimitingRule)ServiceProvider.GetRequiredService(customRuleType)); - } - - return rules; - } - - protected virtual OperationRateLimitingResult AggregateResults( - List ruleResults, - OperationRateLimitingPolicy policy) - { - var isAllowed = ruleResults.All(r => r.IsAllowed); - var mostRestrictive = ruleResults - .OrderBy(r => r.RemainingCount) - .ThenByDescending(r => r.RetryAfter ?? TimeSpan.Zero) - .First(); - - return new OperationRateLimitingResult - { - IsAllowed = isAllowed, - RemainingCount = mostRestrictive.RemainingCount, - MaxCount = mostRestrictive.MaxCount, - CurrentCount = mostRestrictive.CurrentCount, - RetryAfter = ruleResults.Any(r => !r.IsAllowed && r.RetryAfter.HasValue) - ? ruleResults - .Where(r => !r.IsAllowed && r.RetryAfter.HasValue) - .Select(r => r.RetryAfter!.Value) - .Max() - : null, - WindowDuration = mostRestrictive.WindowDuration, - RuleResults = ruleResults - }; - } - - protected virtual void ThrowRateLimitException( - OperationRateLimitingPolicy policy, - OperationRateLimitingResult result, - OperationRateLimitingContext context) - { - var formatter = context.ServiceProvider.GetRequiredService(); - - var exception = new AbpOperationRateLimitingException( - policy.Name, - result, - policy.ErrorCode); - - if (result.RetryAfter.HasValue) - { - exception.SetRetryAfterFormatted(formatter.Format(result.RetryAfter.Value)); - } - - if (result.WindowDuration > TimeSpan.Zero) - { - exception.SetWindowDescriptionFormatted(formatter.Format(result.WindowDuration)); - } - - if (result.RuleResults != null) - { - var ruleDetails = new List>(); - foreach (var ruleResult in result.RuleResults) - { - ruleDetails.Add(new Dictionary - { - ["RuleName"] = ruleResult.RuleName, - ["IsAllowed"] = ruleResult.IsAllowed, - ["MaxCount"] = ruleResult.MaxCount, - ["RemainingCount"] = ruleResult.RemainingCount, - ["CurrentCount"] = ruleResult.CurrentCount, - ["WindowDurationSeconds"] = (int)ruleResult.WindowDuration.TotalSeconds, - ["WindowDescription"] = ruleResult.WindowDuration > TimeSpan.Zero - ? formatter.Format(ruleResult.WindowDuration) - : string.Empty, - ["RetryAfterSeconds"] = (int)(ruleResult.RetryAfter?.TotalSeconds ?? 0), - ["RetryAfter"] = ruleResult.RetryAfter.HasValue - ? formatter.Format(ruleResult.RetryAfter.Value) - : string.Empty - }); - } - - exception.WithData("RuleDetails", ruleDetails); - } - - foreach (var kvp in context.ExtraProperties) - { - exception.WithData(kvp.Key, kvp.Value!); - } - - throw exception; - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs deleted file mode 100644 index df8d195aab..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Threading.Tasks; - -namespace Volo.Abp.OperationRateLimiting; - -public static class OperationRateLimitingCheckerExtensions -{ - public static Task CheckAsync( - this IOperationRateLimitingChecker checker, - string policyName, - string parameter) - { - return checker.CheckAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); - } - - public static Task IsAllowedAsync( - this IOperationRateLimitingChecker checker, - string policyName, - string parameter) - { - return checker.IsAllowedAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); - } - - public static Task GetStatusAsync( - this IOperationRateLimitingChecker checker, - string policyName, - string parameter) - { - return checker.GetStatusAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); - } - - public static Task ResetAsync( - this IOperationRateLimitingChecker checker, - string policyName, - string parameter) - { - return checker.ResetAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs deleted file mode 100644 index f75c06b05b..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.Extensions.DependencyInjection; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingContext -{ - /// - /// Optional parameter passed by the caller. - /// Used as the partition key by PartitionByParameter() (required), - /// and as a fallback by PartitionByEmail() and PartitionByPhoneNumber(). - /// Can be email, phone number, user id, resource id, or any string. - /// - public string? Parameter { get; set; } - - /// - /// Additional properties that can be read by custom implementations - /// and are forwarded to the exception's Data dictionary when the rate limit is exceeded. - /// - public Dictionary ExtraProperties { get; set; } = new(); - - /// - /// The service provider for resolving services. - /// Set automatically by the checker. - /// - public IServiceProvider ServiceProvider { get; set; } = default!; - - public T GetRequiredService() where T : notnull - => ServiceProvider.GetRequiredService(); - - public T? GetService() => ServiceProvider.GetService(); -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs deleted file mode 100644 index 6659947099..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingResult -{ - public bool IsAllowed { get; set; } - - public int RemainingCount { get; set; } - - public int MaxCount { get; set; } - - public int CurrentCount { get; set; } - - public TimeSpan? RetryAfter { get; set; } - - public TimeSpan WindowDuration { get; set; } - - /// - /// Detailed results per rule (for composite policies). - /// - public List? RuleResults { get; set; } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs deleted file mode 100644 index d725b8f7f2..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingRuleResult -{ - public string RuleName { get; set; } = default!; - - public bool IsAllowed { get; set; } - - public int CurrentCount { get; set; } - - public int RemainingCount { get; set; } - - public int MaxCount { get; set; } - - public TimeSpan? RetryAfter { get; set; } - - public TimeSpan WindowDuration { get; set; } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs deleted file mode 100644 index 88cfb2c6ff..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using Volo.Abp.ExceptionHandling; - -namespace Volo.Abp.OperationRateLimiting; - -public class AbpOperationRateLimitingException : BusinessException, IHasHttpStatusCode -{ - public string PolicyName { get; } - - public OperationRateLimitingResult Result { get; } - - public int HttpStatusCode => 429; - - public AbpOperationRateLimitingException( - string policyName, - OperationRateLimitingResult result, - string? errorCode = null) - : base(code: errorCode ?? ResolveDefaultErrorCode(result)) - { - PolicyName = policyName; - Result = result; - - WithData("PolicyName", policyName); - WithData("MaxCount", result.MaxCount); - WithData("CurrentCount", result.CurrentCount); - WithData("RemainingCount", result.RemainingCount); - WithData("RetryAfterSeconds", (int)(result.RetryAfter?.TotalSeconds ?? 0)); - WithData("RetryAfterMinutes", (int)(result.RetryAfter?.TotalMinutes ?? 0)); - WithData("WindowDurationSeconds", (int)result.WindowDuration.TotalSeconds); - } - - internal void SetRetryAfterFormatted(string formattedRetryAfter) - { - WithData("RetryAfter", formattedRetryAfter); - } - - internal void SetWindowDescriptionFormatted(string formattedWindowDescription) - { - WithData("WindowDescription", formattedWindowDescription); - } - - private static string ResolveDefaultErrorCode(OperationRateLimitingResult result) - { - return result.RetryAfter.HasValue - ? AbpOperationRateLimitingErrorCodes.ExceedLimit - : AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently; - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs deleted file mode 100644 index e69dd7082b..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using Microsoft.Extensions.Localization; -using Volo.Abp.DependencyInjection; - -namespace Volo.Abp.OperationRateLimiting; - -public class DefaultOperationRateLimitingFormatter - : IOperationRateLimitingFormatter, ITransientDependency -{ - protected IStringLocalizer Localizer { get; } - - public DefaultOperationRateLimitingFormatter( - IStringLocalizer localizer) - { - Localizer = localizer; - } - - public virtual string Format(TimeSpan duration) - { - if (duration.TotalDays >= 365) - { - var years = (int)(duration.TotalDays / 365); - var remainingDays = (int)(duration.TotalDays % 365); - var months = remainingDays / 30; - return months > 0 - ? Localizer["RetryAfter:YearsAndMonths", years, months] - : Localizer["RetryAfter:Years", years]; - } - - if (duration.TotalDays >= 30) - { - var months = (int)(duration.TotalDays / 30); - var remainingDays = (int)(duration.TotalDays % 30); - return remainingDays > 0 - ? Localizer["RetryAfter:MonthsAndDays", months, remainingDays] - : Localizer["RetryAfter:Months", months]; - } - - if (duration.TotalDays >= 1) - { - var days = (int)duration.TotalDays; - var hours = duration.Hours; - return hours > 0 - ? Localizer["RetryAfter:DaysAndHours", days, hours] - : Localizer["RetryAfter:Days", days]; - } - - if (duration.TotalHours >= 1) - { - var hours = (int)duration.TotalHours; - var minutes = duration.Minutes; - return minutes > 0 - ? Localizer["RetryAfter:HoursAndMinutes", hours, minutes] - : Localizer["RetryAfter:Hours", hours]; - } - - if (duration.TotalMinutes >= 1) - { - var minutes = (int)duration.TotalMinutes; - var seconds = duration.Seconds; - return seconds > 0 - ? Localizer["RetryAfter:MinutesAndSeconds", minutes, seconds] - : Localizer["RetryAfter:Minutes", minutes]; - } - - return Localizer["RetryAfter:Seconds", (int)duration.TotalSeconds]; - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs deleted file mode 100644 index 7e6370e215..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System; - -namespace Volo.Abp.OperationRateLimiting; - -public interface IOperationRateLimitingFormatter -{ - string Format(TimeSpan duration); -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json deleted file mode 100644 index 3ca1c8f042..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "ar", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "تم تجاوز حد معدل العملية. يمكنك المحاولة مرة أخرى بعد {RetryAfter}.", - "RetryAfter:Years": "{0} سنة/سنوات", - "RetryAfter:YearsAndMonths": "{0} سنة/سنوات و {1} شهر/أشهر", - "RetryAfter:Months": "{0} شهر/أشهر", - "RetryAfter:MonthsAndDays": "{0} شهر/أشهر و {1} يوم/أيام", - "RetryAfter:Days": "{0} يوم/أيام", - "RetryAfter:DaysAndHours": "{0} يوم/أيام و {1} ساعة/ساعات", - "RetryAfter:Hours": "{0} ساعة/ساعات", - "RetryAfter:HoursAndMinutes": "{0} ساعة/ساعات و {1} دقيقة/دقائق", - "RetryAfter:Minutes": "{0} دقيقة/دقائق", - "RetryAfter:MinutesAndSeconds": "{0} دقيقة/دقائق و {1} ثانية/ثوان", - "RetryAfter:Seconds": "{0} ثانية/ثوان", - "Volo.Abp.OperationRateLimiting:010002": "تم تجاوز حد معدل العملية. هذا الطلب مرفوض بشكل دائم." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json deleted file mode 100644 index 44cfb86437..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "cs", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Překročen limit rychlosti operace. Můžete to zkusit znovu za {RetryAfter}.", - "RetryAfter:Years": "{0} rok(y/let)", - "RetryAfter:YearsAndMonths": "{0} rok(y/let) a {1} měsíc(e/ů)", - "RetryAfter:Months": "{0} měsíc(e/ů)", - "RetryAfter:MonthsAndDays": "{0} měsíc(e/ů) a {1} den/dny/dní", - "RetryAfter:Days": "{0} den/dny/dní", - "RetryAfter:DaysAndHours": "{0} den/dny/dní a {1} hodina/hodiny/hodin", - "RetryAfter:Hours": "{0} hodina/hodiny/hodin", - "RetryAfter:HoursAndMinutes": "{0} hodina/hodiny/hodin a {1} minuta/minuty/minut", - "RetryAfter:Minutes": "{0} minuta/minuty/minut", - "RetryAfter:MinutesAndSeconds": "{0} minuta/minuty/minut a {1} sekunda/sekundy/sekund", - "RetryAfter:Seconds": "{0} sekunda/sekundy/sekund", - "Volo.Abp.OperationRateLimiting:010002": "Byl překročen limit četnosti operace. Tento požadavek je trvale zamítnut." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json deleted file mode 100644 index 44286d68a8..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "de", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Betriebsratenlimit überschritten. Sie können es nach {RetryAfter} erneut versuchen.", - "RetryAfter:Years": "{0} Jahr(e)", - "RetryAfter:YearsAndMonths": "{0} Jahr(e) und {1} Monat(e)", - "RetryAfter:Months": "{0} Monat(e)", - "RetryAfter:MonthsAndDays": "{0} Monat(e) und {1} Tag(e)", - "RetryAfter:Days": "{0} Tag(e)", - "RetryAfter:DaysAndHours": "{0} Tag(e) und {1} Stunde(n)", - "RetryAfter:Hours": "{0} Stunde(n)", - "RetryAfter:HoursAndMinutes": "{0} Stunde(n) und {1} Minute(n)", - "RetryAfter:Minutes": "{0} Minute(n)", - "RetryAfter:MinutesAndSeconds": "{0} Minute(n) und {1} Sekunde(n)", - "RetryAfter:Seconds": "{0} Sekunde(n)", - "Volo.Abp.OperationRateLimiting:010002": "Das Vorgangshäufigkeitslimit wurde überschritten. Diese Anfrage wird dauerhaft abgelehnt." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json deleted file mode 100644 index a688778eb5..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "el", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Υπέρβαση ορίου ρυθμού λειτουργίας. Μπορείτε να δοκιμάσετε ξανά μετά από {RetryAfter}.", - "RetryAfter:Years": "{0} έτος/η", - "RetryAfter:YearsAndMonths": "{0} έτος/η και {1} μήνας/ες", - "RetryAfter:Months": "{0} μήνας/ες", - "RetryAfter:MonthsAndDays": "{0} μήνας/ες και {1} ημέρα/ες", - "RetryAfter:Days": "{0} ημέρα/ες", - "RetryAfter:DaysAndHours": "{0} ημέρα/ες και {1} ώρα/ες", - "RetryAfter:Hours": "{0} ώρα/ες", - "RetryAfter:HoursAndMinutes": "{0} ώρα/ες και {1} λεπτό/ά", - "RetryAfter:Minutes": "{0} λεπτό/ά", - "RetryAfter:MinutesAndSeconds": "{0} λεπτό/ά και {1} δευτερόλεπτο/α", - "RetryAfter:Seconds": "{0} δευτερόλεπτο/α", - "Volo.Abp.OperationRateLimiting:010002": "Υπερβλήθηκε το όριο συχνότητας λειτουργίας. Αυτό το αίτημα απορρίπτεται μόνιμα." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json deleted file mode 100644 index 4eab97f134..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "en-GB", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.", - "RetryAfter:Years": "{0} year(s)", - "RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)", - "RetryAfter:Months": "{0} month(s)", - "RetryAfter:MonthsAndDays": "{0} month(s) and {1} day(s)", - "RetryAfter:Days": "{0} day(s)", - "RetryAfter:DaysAndHours": "{0} day(s) and {1} hour(s)", - "RetryAfter:Hours": "{0} hour(s)", - "RetryAfter:HoursAndMinutes": "{0} hour(s) and {1} minute(s)", - "RetryAfter:Minutes": "{0} minute(s)", - "RetryAfter:MinutesAndSeconds": "{0} minute(s) and {1} second(s)", - "RetryAfter:Seconds": "{0} second(s)", - "Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json deleted file mode 100644 index fc41cabc2f..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "en", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.", - "RetryAfter:Years": "{0} year(s)", - "RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)", - "RetryAfter:Months": "{0} month(s)", - "RetryAfter:MonthsAndDays": "{0} month(s) and {1} day(s)", - "RetryAfter:Days": "{0} day(s)", - "RetryAfter:DaysAndHours": "{0} day(s) and {1} hour(s)", - "RetryAfter:Hours": "{0} hour(s)", - "RetryAfter:HoursAndMinutes": "{0} hour(s) and {1} minute(s)", - "RetryAfter:Minutes": "{0} minute(s)", - "RetryAfter:MinutesAndSeconds": "{0} minute(s) and {1} second(s)", - "RetryAfter:Seconds": "{0} second(s)", - "Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json deleted file mode 100644 index ce13a9bceb..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "es", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Se ha excedido el límite de tasa de operación. Puede intentarlo de nuevo después de {RetryAfter}.", - "RetryAfter:Years": "{0} año(s)", - "RetryAfter:YearsAndMonths": "{0} año(s) y {1} mes(es)", - "RetryAfter:Months": "{0} mes(es)", - "RetryAfter:MonthsAndDays": "{0} mes(es) y {1} día(s)", - "RetryAfter:Days": "{0} día(s)", - "RetryAfter:DaysAndHours": "{0} día(s) y {1} hora(s)", - "RetryAfter:Hours": "{0} hora(s)", - "RetryAfter:HoursAndMinutes": "{0} hora(s) y {1} minuto(s)", - "RetryAfter:Minutes": "{0} minuto(s)", - "RetryAfter:MinutesAndSeconds": "{0} minuto(s) y {1} segundo(s)", - "RetryAfter:Seconds": "{0} segundo(s)", - "Volo.Abp.OperationRateLimiting:010002": "Se superó el límite de frecuencia de operación. Esta solicitud está permanentemente denegada." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json deleted file mode 100644 index 0f200b5472..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "fa", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "محدودیت نرخ عملیات فراتر رفته است. می‌توانید بعد از {RetryAfter} دوباره تلاش کنید.", - "RetryAfter:Years": "{0} سال", - "RetryAfter:YearsAndMonths": "{0} سال و {1} ماه", - "RetryAfter:Months": "{0} ماه", - "RetryAfter:MonthsAndDays": "{0} ماه و {1} روز", - "RetryAfter:Days": "{0} روز", - "RetryAfter:DaysAndHours": "{0} روز و {1} ساعت", - "RetryAfter:Hours": "{0} ساعت", - "RetryAfter:HoursAndMinutes": "{0} ساعت و {1} دقیقه", - "RetryAfter:Minutes": "{0} دقیقه", - "RetryAfter:MinutesAndSeconds": "{0} دقیقه و {1} ثانیه", - "RetryAfter:Seconds": "{0} ثانیه", - "Volo.Abp.OperationRateLimiting:010002": "محدودیت نرخ عملیات از حد مجاز فراتر رفت. این درخواست به طور دائمی رد شده است." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json deleted file mode 100644 index bcb88bf9ed..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "fi", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Toiminnon nopeusraja ylitetty. Voit yrittää uudelleen {RetryAfter} kuluttua.", - "RetryAfter:Years": "{0} vuosi/vuotta", - "RetryAfter:YearsAndMonths": "{0} vuosi/vuotta ja {1} kuukausi/kuukautta", - "RetryAfter:Months": "{0} kuukausi/kuukautta", - "RetryAfter:MonthsAndDays": "{0} kuukausi/kuukautta ja {1} päivä/päivää", - "RetryAfter:Days": "{0} päivä/päivää", - "RetryAfter:DaysAndHours": "{0} päivä/päivää ja {1} tunti/tuntia", - "RetryAfter:Hours": "{0} tunti/tuntia", - "RetryAfter:HoursAndMinutes": "{0} tunti/tuntia ja {1} minuutti/minuuttia", - "RetryAfter:Minutes": "{0} minuutti/minuuttia", - "RetryAfter:MinutesAndSeconds": "{0} minuutti/minuuttia ja {1} sekunti/sekuntia", - "RetryAfter:Seconds": "{0} sekunti/sekuntia", - "Volo.Abp.OperationRateLimiting:010002": "Toiminnan nopeusraja ylitettiin. Tämä pyyntö on pysyvästi hylätty." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json deleted file mode 100644 index dca23d7a80..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "fr", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Limite de taux d'opération dépassée. Vous pouvez réessayer après {RetryAfter}.", - "RetryAfter:Years": "{0} an(s)", - "RetryAfter:YearsAndMonths": "{0} an(s) et {1} mois", - "RetryAfter:Months": "{0} mois", - "RetryAfter:MonthsAndDays": "{0} mois et {1} jour(s)", - "RetryAfter:Days": "{0} jour(s)", - "RetryAfter:DaysAndHours": "{0} jour(s) et {1} heure(s)", - "RetryAfter:Hours": "{0} heure(s)", - "RetryAfter:HoursAndMinutes": "{0} heure(s) et {1} minute(s)", - "RetryAfter:Minutes": "{0} minute(s)", - "RetryAfter:MinutesAndSeconds": "{0} minute(s) et {1} seconde(s)", - "RetryAfter:Seconds": "{0} seconde(s)", - "Volo.Abp.OperationRateLimiting:010002": "La limite de fréquence d'opération a été dépassée. Cette demande est définitivement refusée." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json deleted file mode 100644 index a84a1e44a8..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "hi", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "ऑपरेशन दर सीमा पार हो गई। आप {RetryAfter} के बाद पुनः प्रयास कर सकते हैं।", - "RetryAfter:Years": "{0} वर्ष", - "RetryAfter:YearsAndMonths": "{0} वर्ष और {1} महीना/महीने", - "RetryAfter:Months": "{0} महीना/महीने", - "RetryAfter:MonthsAndDays": "{0} महीना/महीने और {1} दिन", - "RetryAfter:Days": "{0} दिन", - "RetryAfter:DaysAndHours": "{0} दिन और {1} घंटा/घंटे", - "RetryAfter:Hours": "{0} घंटा/घंटे", - "RetryAfter:HoursAndMinutes": "{0} घंटा/घंटे और {1} मिनट", - "RetryAfter:Minutes": "{0} मिनट", - "RetryAfter:MinutesAndSeconds": "{0} मिनट और {1} सेकंड", - "RetryAfter:Seconds": "{0} सेकंड", - "Volo.Abp.OperationRateLimiting:010002": "ऑपरेशन दर सीमा पार हो गई। यह अनुरोध स्थायी रूप से अस्वीकृत है।" - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json deleted file mode 100644 index c6597be78e..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "hr", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Prekoračeno ograničenje brzine operacije. Možete pokušati ponovo nakon {RetryAfter}.", - "RetryAfter:Years": "{0} godina/e", - "RetryAfter:YearsAndMonths": "{0} godina/e i {1} mjesec/i", - "RetryAfter:Months": "{0} mjesec/i", - "RetryAfter:MonthsAndDays": "{0} mjesec/i i {1} dan/a", - "RetryAfter:Days": "{0} dan/a", - "RetryAfter:DaysAndHours": "{0} dan/a i {1} sat/i", - "RetryAfter:Hours": "{0} sat/i", - "RetryAfter:HoursAndMinutes": "{0} sat/i i {1} minuta/e", - "RetryAfter:Minutes": "{0} minuta/e", - "RetryAfter:MinutesAndSeconds": "{0} minuta/e i {1} sekunda/e", - "RetryAfter:Seconds": "{0} sekunda/e", - "Volo.Abp.OperationRateLimiting:010002": "Prekoračeno je ograničenje brzine operacije. Ovaj zahtjev je trajno odbijen." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json deleted file mode 100644 index f72dfea397..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "hu", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "A műveleti sebességkorlát túllépve. Újra próbálkozhat {RetryAfter} múlva.", - "RetryAfter:Years": "{0} év", - "RetryAfter:YearsAndMonths": "{0} év és {1} hónap", - "RetryAfter:Months": "{0} hónap", - "RetryAfter:MonthsAndDays": "{0} hónap és {1} nap", - "RetryAfter:Days": "{0} nap", - "RetryAfter:DaysAndHours": "{0} nap és {1} óra", - "RetryAfter:Hours": "{0} óra", - "RetryAfter:HoursAndMinutes": "{0} óra és {1} perc", - "RetryAfter:Minutes": "{0} perc", - "RetryAfter:MinutesAndSeconds": "{0} perc és {1} másodperc", - "RetryAfter:Seconds": "{0} másodperc", - "Volo.Abp.OperationRateLimiting:010002": "A műveleti ráta korlátja túllépve. Ez a kérés véglegesen elutasítva." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json deleted file mode 100644 index cce36e42cd..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "is", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Aðgerðarhraðatakmörk náð. Þú getur reynt aftur eftir {RetryAfter}.", - "RetryAfter:Years": "{0} ár", - "RetryAfter:YearsAndMonths": "{0} ár og {1} mánuð(ir)", - "RetryAfter:Months": "{0} mánuð(ur/ir)", - "RetryAfter:MonthsAndDays": "{0} mánuð(ur/ir) og {1} dag(ur/ar)", - "RetryAfter:Days": "{0} dag(ur/ar)", - "RetryAfter:DaysAndHours": "{0} dag(ur/ar) og {1} klukkustund(ir)", - "RetryAfter:Hours": "{0} klukkustund(ir)", - "RetryAfter:HoursAndMinutes": "{0} klukkustund(ir) og {1} mínúta/úr", - "RetryAfter:Minutes": "{0} mínúta/úr", - "RetryAfter:MinutesAndSeconds": "{0} mínúta/úr og {1} sekúnda/úr", - "RetryAfter:Seconds": "{0} sekúnda/úr", - "Volo.Abp.OperationRateLimiting:010002": "Farið var yfir takmörk á rekstrartíðni. Þessari beiðni er varanlega hafnað." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json deleted file mode 100644 index f602fd1a66..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "it", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Limite di frequenza operazione superato. Puoi riprovare dopo {RetryAfter}.", - "RetryAfter:Years": "{0} anno/i", - "RetryAfter:YearsAndMonths": "{0} anno/i e {1} mese/i", - "RetryAfter:Months": "{0} mese/i", - "RetryAfter:MonthsAndDays": "{0} mese/i e {1} giorno/i", - "RetryAfter:Days": "{0} giorno/i", - "RetryAfter:DaysAndHours": "{0} giorno/i e {1} ora/e", - "RetryAfter:Hours": "{0} ora/e", - "RetryAfter:HoursAndMinutes": "{0} ora/e e {1} minuto/i", - "RetryAfter:Minutes": "{0} minuto/i", - "RetryAfter:MinutesAndSeconds": "{0} minuto/i e {1} secondo/i", - "RetryAfter:Seconds": "{0} secondo/i", - "Volo.Abp.OperationRateLimiting:010002": "Limite di frequenza operazione superato. Questa richiesta è permanentemente negata." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json deleted file mode 100644 index bb21e7f313..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "nl", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Bewerkingssnelheidslimiet overschreden. U kunt het opnieuw proberen na {RetryAfter}.", - "RetryAfter:Years": "{0} jaar", - "RetryAfter:YearsAndMonths": "{0} jaar en {1} maand(en)", - "RetryAfter:Months": "{0} maand(en)", - "RetryAfter:MonthsAndDays": "{0} maand(en) en {1} dag(en)", - "RetryAfter:Days": "{0} dag(en)", - "RetryAfter:DaysAndHours": "{0} dag(en) en {1} uur", - "RetryAfter:Hours": "{0} uur", - "RetryAfter:HoursAndMinutes": "{0} uur en {1} minuut/minuten", - "RetryAfter:Minutes": "{0} minuut/minuten", - "RetryAfter:MinutesAndSeconds": "{0} minuut/minuten en {1} seconde(n)", - "RetryAfter:Seconds": "{0} seconde(n)", - "Volo.Abp.OperationRateLimiting:010002": "Het bewerkingsfrequentielimiet is overschreden. Dit verzoek wordt permanent geweigerd." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json deleted file mode 100644 index e4503f6aa7..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "pl-PL", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Przekroczono limit częstotliwości operacji. Możesz spróbować ponownie po {RetryAfter}.", - "RetryAfter:Years": "{0} rok/lat", - "RetryAfter:YearsAndMonths": "{0} rok/lat i {1} miesiąc/miesięcy", - "RetryAfter:Months": "{0} miesiąc/miesięcy", - "RetryAfter:MonthsAndDays": "{0} miesiąc/miesięcy i {1} dzień/dni", - "RetryAfter:Days": "{0} dzień/dni", - "RetryAfter:DaysAndHours": "{0} dzień/dni i {1} godzina/godzin", - "RetryAfter:Hours": "{0} godzina/godzin", - "RetryAfter:HoursAndMinutes": "{0} godzina/godzin i {1} minuta/minut", - "RetryAfter:Minutes": "{0} minuta/minut", - "RetryAfter:MinutesAndSeconds": "{0} minuta/minut i {1} sekunda/sekund", - "RetryAfter:Seconds": "{0} sekunda/sekund", - "Volo.Abp.OperationRateLimiting:010002": "Przekroczono limit częstotliwości operacji. To żądanie jest trwale odrzucone." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json deleted file mode 100644 index fb6f873805..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "pt-BR", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Limite de taxa de operação excedido. Você pode tentar novamente após {RetryAfter}.", - "RetryAfter:Years": "{0} ano(s)", - "RetryAfter:YearsAndMonths": "{0} ano(s) e {1} mês/meses", - "RetryAfter:Months": "{0} mês/meses", - "RetryAfter:MonthsAndDays": "{0} mês/meses e {1} dia(s)", - "RetryAfter:Days": "{0} dia(s)", - "RetryAfter:DaysAndHours": "{0} dia(s) e {1} hora(s)", - "RetryAfter:Hours": "{0} hora(s)", - "RetryAfter:HoursAndMinutes": "{0} hora(s) e {1} minuto(s)", - "RetryAfter:Minutes": "{0} minuto(s)", - "RetryAfter:MinutesAndSeconds": "{0} minuto(s) e {1} segundo(s)", - "RetryAfter:Seconds": "{0} segundo(s)", - "Volo.Abp.OperationRateLimiting:010002": "Limite de taxa de operação excedido. Esta solicitação está permanentemente negada." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json deleted file mode 100644 index 178042fa85..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "ro-RO", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Limita ratei de operare a fost depășită. Puteți încerca din nou după {RetryAfter}.", - "RetryAfter:Years": "{0} an/ani", - "RetryAfter:YearsAndMonths": "{0} an/ani și {1} lună/luni", - "RetryAfter:Months": "{0} lună/luni", - "RetryAfter:MonthsAndDays": "{0} lună/luni și {1} zi/zile", - "RetryAfter:Days": "{0} zi/zile", - "RetryAfter:DaysAndHours": "{0} zi/zile și {1} oră/ore", - "RetryAfter:Hours": "{0} oră/ore", - "RetryAfter:HoursAndMinutes": "{0} oră/ore și {1} minut(e)", - "RetryAfter:Minutes": "{0} minut(e)", - "RetryAfter:MinutesAndSeconds": "{0} minut(e) și {1} secundă/secunde", - "RetryAfter:Seconds": "{0} secundă/secunde", - "Volo.Abp.OperationRateLimiting:010002": "Limita de rată a operației a fost depășită. Această solicitare este permanent refuzată." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json deleted file mode 100644 index 5260a9c90c..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "ru", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Превышен лимит частоты операций. Вы можете повторить попытку через {RetryAfter}.", - "RetryAfter:Years": "{0} год/лет", - "RetryAfter:YearsAndMonths": "{0} год/лет и {1} месяц/месяцев", - "RetryAfter:Months": "{0} месяц/месяцев", - "RetryAfter:MonthsAndDays": "{0} месяц/месяцев и {1} день/дней", - "RetryAfter:Days": "{0} день/дней", - "RetryAfter:DaysAndHours": "{0} день/дней и {1} час/часов", - "RetryAfter:Hours": "{0} час/часов", - "RetryAfter:HoursAndMinutes": "{0} час/часов и {1} минута/минут", - "RetryAfter:Minutes": "{0} минута/минут", - "RetryAfter:MinutesAndSeconds": "{0} минута/минут и {1} секунда/секунд", - "RetryAfter:Seconds": "{0} секунда/секунд", - "Volo.Abp.OperationRateLimiting:010002": "Превышен лимит частоты операций. Этот запрос постоянно отклонён." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json deleted file mode 100644 index 73cdbb2b5d..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "sk", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Prekročený limit rýchlosti operácie. Môžete to skúsiť znova po {RetryAfter}.", - "RetryAfter:Years": "{0} rok/rokov", - "RetryAfter:YearsAndMonths": "{0} rok/rokov a {1} mesiac/mesiacov", - "RetryAfter:Months": "{0} mesiac/mesiacov", - "RetryAfter:MonthsAndDays": "{0} mesiac/mesiacov a {1} deň/dní", - "RetryAfter:Days": "{0} deň/dní", - "RetryAfter:DaysAndHours": "{0} deň/dní a {1} hodina/hodín", - "RetryAfter:Hours": "{0} hodina/hodín", - "RetryAfter:HoursAndMinutes": "{0} hodina/hodín a {1} minúta/minút", - "RetryAfter:Minutes": "{0} minúta/minút", - "RetryAfter:MinutesAndSeconds": "{0} minúta/minút a {1} sekunda/sekúnd", - "RetryAfter:Seconds": "{0} sekunda/sekúnd", - "Volo.Abp.OperationRateLimiting:010002": "Bol prekročený limit frekvencie operácie. Táto požiadavka je trvalo zamietnutá." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json deleted file mode 100644 index 333f51efb5..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "sl", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Presežena omejitev hitrosti operacije. Poskusite lahko znova čez {RetryAfter}.", - "RetryAfter:Years": "{0} leto/let", - "RetryAfter:YearsAndMonths": "{0} leto/let in {1} mesec/mesecev", - "RetryAfter:Months": "{0} mesec/mesecev", - "RetryAfter:MonthsAndDays": "{0} mesec/mesecev in {1} dan/dni", - "RetryAfter:Days": "{0} dan/dni", - "RetryAfter:DaysAndHours": "{0} dan/dni in {1} ura/ur", - "RetryAfter:Hours": "{0} ura/ur", - "RetryAfter:HoursAndMinutes": "{0} ura/ur in {1} minuta/minut", - "RetryAfter:Minutes": "{0} minuta/minut", - "RetryAfter:MinutesAndSeconds": "{0} minuta/minut in {1} sekunda/sekund", - "RetryAfter:Seconds": "{0} sekunda/sekund", - "Volo.Abp.OperationRateLimiting:010002": "Prekoračena je omejitev hitrosti operacije. Ta zahteva je trajno zavrnjena." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json deleted file mode 100644 index ef5172867c..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "sv", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Hastighetsgränsen för operationen har överskridits. Du kan försöka igen efter {RetryAfter}.", - "RetryAfter:Years": "{0} år", - "RetryAfter:YearsAndMonths": "{0} år och {1} månad(er)", - "RetryAfter:Months": "{0} månad(er)", - "RetryAfter:MonthsAndDays": "{0} månad(er) och {1} dag(ar)", - "RetryAfter:Days": "{0} dag(ar)", - "RetryAfter:DaysAndHours": "{0} dag(ar) och {1} timme/timmar", - "RetryAfter:Hours": "{0} timme/timmar", - "RetryAfter:HoursAndMinutes": "{0} timme/timmar och {1} minut(er)", - "RetryAfter:Minutes": "{0} minut(er)", - "RetryAfter:MinutesAndSeconds": "{0} minut(er) och {1} sekund(er)", - "RetryAfter:Seconds": "{0} sekund(er)", - "Volo.Abp.OperationRateLimiting:010002": "Hastighetsgränsen för operationen har överskridits. Denna förfrågan är permanent nekad." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json deleted file mode 100644 index 0480003f9c..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "tr", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "İşlem hız sınırı aşıldı. {RetryAfter} sonra tekrar deneyebilirsiniz.", - "RetryAfter:Years": "{0} yıl", - "RetryAfter:YearsAndMonths": "{0} yıl ve {1} ay", - "RetryAfter:Months": "{0} ay", - "RetryAfter:MonthsAndDays": "{0} ay ve {1} gün", - "RetryAfter:Days": "{0} gün", - "RetryAfter:DaysAndHours": "{0} gün ve {1} saat", - "RetryAfter:Hours": "{0} saat", - "RetryAfter:HoursAndMinutes": "{0} saat ve {1} dakika", - "RetryAfter:Minutes": "{0} dakika", - "RetryAfter:MinutesAndSeconds": "{0} dakika ve {1} saniye", - "RetryAfter:Seconds": "{0} saniye", - "Volo.Abp.OperationRateLimiting:010002": "İşlem hızı sınırı aşıldı. Bu istek kalıcı olarak reddedildi." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json deleted file mode 100644 index 53059f819c..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "vi", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "Đã vượt quá giới hạn tốc độ thao tác. Bạn có thể thử lại sau {RetryAfter}.", - "RetryAfter:Years": "{0} năm", - "RetryAfter:YearsAndMonths": "{0} năm và {1} tháng", - "RetryAfter:Months": "{0} tháng", - "RetryAfter:MonthsAndDays": "{0} tháng và {1} ngày", - "RetryAfter:Days": "{0} ngày", - "RetryAfter:DaysAndHours": "{0} ngày và {1} giờ", - "RetryAfter:Hours": "{0} giờ", - "RetryAfter:HoursAndMinutes": "{0} giờ và {1} phút", - "RetryAfter:Minutes": "{0} phút", - "RetryAfter:MinutesAndSeconds": "{0} phút và {1} giây", - "RetryAfter:Seconds": "{0} giây", - "Volo.Abp.OperationRateLimiting:010002": "Vượt quá giới hạn tần suất thao tác. Yêu cầu này bị từ chối vĩnh viễn." - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json deleted file mode 100644 index 632d0e438f..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "culture": "zh-Hans", - "texts": { - "Volo.Abp.OperationRateLimiting:010001": "操作频率超出限制。请在 {RetryAfter} 后重试。", - "RetryAfter:Years": "{0} 年", - "RetryAfter:YearsAndMonths": "{0} 年 {1} 个月", - "RetryAfter:Months": "{0} 个月", - "RetryAfter:MonthsAndDays": "{0} 个月 {1} 天", - "RetryAfter:Days": "{0} 天", - "RetryAfter:DaysAndHours": "{0} 天 {1} 小时", - "RetryAfter:Hours": "{0} 小时", - "RetryAfter:HoursAndMinutes": "{0} 小时 {1} 分钟", - "RetryAfter:Minutes": "{0} 分钟", - "RetryAfter:MinutesAndSeconds": "{0} 分钟 {1} 秒", - "RetryAfter:Seconds": "{0} 秒", - "Volo.Abp.OperationRateLimiting:010002": "操作频率超出限制。此请求已被永久拒绝。" - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs deleted file mode 100644 index 305863381a..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Options; -using Volo.Abp.DependencyInjection; - -namespace Volo.Abp.OperationRateLimiting; - -public class DefaultOperationRateLimitingPolicyProvider : IOperationRateLimitingPolicyProvider, ITransientDependency -{ - protected AbpOperationRateLimitingOptions Options { get; } - - public DefaultOperationRateLimitingPolicyProvider(IOptions options) - { - Options = options.Value; - } - - public virtual Task GetAsync(string policyName) - { - if (!Options.Policies.TryGetValue(policyName, out var policy)) - { - throw new AbpException( - $"Operation rate limit policy '{policyName}' was not found. " + - $"Make sure to configure it using AbpOperationRateLimitingOptions.AddPolicy()."); - } - - return Task.FromResult(policy); - } - - public virtual Task> GetListAsync() - { - return Task.FromResult(Options.Policies.Values.ToList()); - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs deleted file mode 100644 index 1f87137a68..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Volo.Abp.OperationRateLimiting; - -public interface IOperationRateLimitingPolicyProvider -{ - Task GetAsync(string policyName); - - Task> GetListAsync(); -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs deleted file mode 100644 index e330bd8e46..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Volo.Abp.OperationRateLimiting; - -public enum OperationRateLimitingPartitionType -{ - Parameter, - CurrentUser, - CurrentTenant, - ClientIp, - Email, - PhoneNumber, - Custom -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs deleted file mode 100644 index 45634e5de1..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingPolicy -{ - public string Name { get; set; } = default!; - - public string? ErrorCode { get; set; } - - public List Rules { get; set; } = new(); - - public List CustomRuleTypes { get; set; } = new(); -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs deleted file mode 100644 index 3a3db933b5..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingPolicyBuilder -{ - private readonly string _name; - private string? _errorCode; - private readonly List _rules = new(); - private readonly List _customRuleTypes = new(); - - public OperationRateLimitingPolicyBuilder(string name) - { - _name = Check.NotNullOrWhiteSpace(name, nameof(name)); - } - - /// - /// Add a built-in rule. Multiple rules are AND-combined. - /// - public OperationRateLimitingPolicyBuilder AddRule( - Action configure) - { - var builder = new OperationRateLimitingRuleBuilder(this); - configure(builder); - if (!builder.IsCommitted) - { - _rules.Add(builder.Build()); - } - return this; - } - - /// - /// Add a custom rule type (resolved from DI). - /// - public OperationRateLimitingPolicyBuilder AddRule() - where TRule : class, IOperationRateLimitingRule - { - _customRuleTypes.Add(typeof(TRule)); - return this; - } - - /// - /// Shortcut: single-rule policy with fixed window. - /// Returns the rule builder for partition configuration. - /// - public OperationRateLimitingRuleBuilder WithFixedWindow( - TimeSpan duration, int maxCount) - { - var builder = new OperationRateLimitingRuleBuilder(this); - builder.WithFixedWindow(duration, maxCount); - return builder; - } - - /// - /// Set a custom ErrorCode for this policy's exception. - /// - public OperationRateLimitingPolicyBuilder WithErrorCode(string errorCode) - { - _errorCode = Check.NotNullOrWhiteSpace(errorCode, nameof(errorCode)); - return this; - } - - /// - /// Clears all rules and custom rule types from this policy builder, - /// allowing a full replacement of the inherited rules. - /// - /// The current builder instance for method chaining. - public OperationRateLimitingPolicyBuilder ClearRules() - { - _rules.Clear(); - _customRuleTypes.Clear(); - return this; - } - - internal static OperationRateLimitingPolicyBuilder FromPolicy(OperationRateLimitingPolicy policy) - { - Check.NotNull(policy, nameof(policy)); - - var builder = new OperationRateLimitingPolicyBuilder(policy.Name); - builder._errorCode = policy.ErrorCode; - builder._rules.AddRange(policy.Rules); - builder._customRuleTypes.AddRange(policy.CustomRuleTypes); - return builder; - } - - internal void AddRuleDefinition(OperationRateLimitingRuleDefinition definition) - { - _rules.Add(definition); - } - - internal OperationRateLimitingPolicy Build() - { - if (_rules.Count == 0 && _customRuleTypes.Count == 0) - { - throw new AbpException( - $"Operation rate limit policy '{_name}' has no rules. " + - "Call AddRule() or WithFixedWindow(...).PartitionBy*() to add at least one rule."); - } - - var duplicate = _rules - .Where(r => r.PartitionType != OperationRateLimitingPartitionType.Custom) - .GroupBy(r => (r.Duration, r.MaxCount, r.PartitionType, r.IsMultiTenant)) - .FirstOrDefault(g => g.Count() > 1); - - if (duplicate != null) - { - var (duration, maxCount, partitionType, isMultiTenant) = duplicate.Key; - throw new AbpException( - $"Operation rate limit policy '{_name}' has duplicate rules with the same " + - $"Duration ({duration}), MaxCount ({maxCount}), PartitionType ({partitionType}), " + - $"and IsMultiTenant ({isMultiTenant}). " + - "Each rule in a policy must have a unique combination of these properties."); - } - - return new OperationRateLimitingPolicy - { - Name = _name, - ErrorCode = _errorCode, - Rules = new List(_rules), - CustomRuleTypes = new List(_customRuleTypes) - }; - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs deleted file mode 100644 index b9f2eacf9a..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingRuleBuilder -{ - private readonly OperationRateLimitingPolicyBuilder _policyBuilder; - private TimeSpan _duration; - private int _maxCount; - private OperationRateLimitingPartitionType? _partitionType; - private Func>? _customPartitionKeyResolver; - private bool _isMultiTenant; - - internal bool IsCommitted { get; private set; } - - internal OperationRateLimitingRuleBuilder(OperationRateLimitingPolicyBuilder policyBuilder) - { - _policyBuilder = policyBuilder; - } - - public OperationRateLimitingRuleBuilder WithFixedWindow( - TimeSpan duration, int maxCount) - { - _duration = duration; - _maxCount = maxCount; - return this; - } - - public OperationRateLimitingRuleBuilder WithMultiTenancy() - { - _isMultiTenant = true; - return this; - } - - /// - /// Use context.Parameter as partition key. - /// - public OperationRateLimitingPolicyBuilder PartitionByParameter() - { - _partitionType = OperationRateLimitingPartitionType.Parameter; - CommitToPolicyBuilder(); - return _policyBuilder; - } - - /// - /// Partition by the current authenticated user (ICurrentUser.Id). - /// Use PartitionByParameter() if you need to specify the user ID explicitly. - /// - public OperationRateLimitingPolicyBuilder PartitionByCurrentUser() - { - _partitionType = OperationRateLimitingPartitionType.CurrentUser; - CommitToPolicyBuilder(); - return _policyBuilder; - } - - /// - /// Partition by the current tenant (ICurrentTenant.Id). Uses "host" when no tenant is active. - /// - public OperationRateLimitingPolicyBuilder PartitionByCurrentTenant() - { - _partitionType = OperationRateLimitingPartitionType.CurrentTenant; - CommitToPolicyBuilder(); - return _policyBuilder; - } - - /// - /// Partition by the client IP address (IWebClientInfoProvider.ClientIpAddress). - /// Use PartitionByParameter() if you need to specify the IP explicitly. - /// - public OperationRateLimitingPolicyBuilder PartitionByClientIp() - { - _partitionType = OperationRateLimitingPartitionType.ClientIp; - CommitToPolicyBuilder(); - return _policyBuilder; - } - - /// - /// Partition by email address. - /// Resolves from context.Parameter, falls back to ICurrentUser.Email. - /// - public OperationRateLimitingPolicyBuilder PartitionByEmail() - { - _partitionType = OperationRateLimitingPartitionType.Email; - CommitToPolicyBuilder(); - return _policyBuilder; - } - - /// - /// Partition by phone number. - /// Resolves from context.Parameter, falls back to ICurrentUser.PhoneNumber. - /// - public OperationRateLimitingPolicyBuilder PartitionByPhoneNumber() - { - _partitionType = OperationRateLimitingPartitionType.PhoneNumber; - CommitToPolicyBuilder(); - return _policyBuilder; - } - - /// - /// Custom async partition key resolver from context. - /// - public OperationRateLimitingPolicyBuilder PartitionBy( - Func> keyResolver) - { - _partitionType = OperationRateLimitingPartitionType.Custom; - _customPartitionKeyResolver = Check.NotNull(keyResolver, nameof(keyResolver)); - CommitToPolicyBuilder(); - return _policyBuilder; - } - - protected virtual void CommitToPolicyBuilder() - { - _policyBuilder.AddRuleDefinition(Build()); - IsCommitted = true; - } - - internal OperationRateLimitingRuleDefinition Build() - { - if (_duration <= TimeSpan.Zero) - { - throw new AbpException( - "Operation rate limit rule requires a positive duration. " + - "Call WithFixedWindow(duration, maxCount) before building the rule."); - } - - if (_maxCount < 0) - { - throw new AbpException( - "Operation rate limit rule requires maxCount >= 0. " + - "Use maxCount: 0 to completely deny all requests (ban policy)."); - } - - if (!_partitionType.HasValue) - { - throw new AbpException( - "Operation rate limit rule requires a partition type. " + - "Call PartitionByParameter(), PartitionByCurrentUser(), PartitionByClientIp(), or another PartitionBy*() method."); - } - - if (_partitionType == OperationRateLimitingPartitionType.Custom && _customPartitionKeyResolver == null) - { - throw new AbpException( - "Custom partition type requires a key resolver. " + - "Call PartitionBy(keyResolver) instead of setting partition type directly."); - } - - return new OperationRateLimitingRuleDefinition - { - Duration = _duration, - MaxCount = _maxCount, - PartitionType = _partitionType.Value, - CustomPartitionKeyResolver = _customPartitionKeyResolver, - IsMultiTenant = _isMultiTenant - }; - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs deleted file mode 100644 index f8d1bcf9e7..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingRuleDefinition -{ - public TimeSpan Duration { get; set; } - - public int MaxCount { get; set; } - - public OperationRateLimitingPartitionType PartitionType { get; set; } - - public Func>? CustomPartitionKeyResolver { get; set; } - - public bool IsMultiTenant { get; set; } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs deleted file mode 100644 index bd869e2c5b..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Threading.Tasks; -using Volo.Abp.AspNetCore.WebClientInfo; -using Volo.Abp.MultiTenancy; -using Volo.Abp.Users; - -namespace Volo.Abp.OperationRateLimiting; - -public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule -{ - private const string HostTenantKey = "host"; - - protected string PolicyName { get; } - protected OperationRateLimitingRuleDefinition Definition { get; } - protected IOperationRateLimitingStore Store { get; } - protected ICurrentUser CurrentUser { get; } - protected ICurrentTenant CurrentTenant { get; } - protected IWebClientInfoProvider WebClientInfoProvider { get; } - - public FixedWindowOperationRateLimitingRule( - string policyName, - OperationRateLimitingRuleDefinition definition, - IOperationRateLimitingStore store, - ICurrentUser currentUser, - ICurrentTenant currentTenant, - IWebClientInfoProvider webClientInfoProvider) - { - PolicyName = policyName; - Definition = definition; - Store = store; - CurrentUser = currentUser; - CurrentTenant = currentTenant; - WebClientInfoProvider = webClientInfoProvider; - } - - public virtual async Task AcquireAsync( - OperationRateLimitingContext context) - { - var partitionKey = await ResolvePartitionKeyAsync(context); - var storeKey = BuildStoreKey(partitionKey); - var storeResult = await Store.IncrementAsync(storeKey, Definition.Duration, Definition.MaxCount); - - return ToRuleResult(storeResult); - } - - public virtual async Task CheckAsync( - OperationRateLimitingContext context) - { - var partitionKey = await ResolvePartitionKeyAsync(context); - var storeKey = BuildStoreKey(partitionKey); - var storeResult = await Store.GetAsync(storeKey, Definition.Duration, Definition.MaxCount); - - return ToRuleResult(storeResult); - } - - public virtual async Task ResetAsync(OperationRateLimitingContext context) - { - var partitionKey = await ResolvePartitionKeyAsync(context); - var storeKey = BuildStoreKey(partitionKey); - await Store.ResetAsync(storeKey); - } - - protected virtual async Task ResolvePartitionKeyAsync(OperationRateLimitingContext context) - { - return Definition.PartitionType switch - { - OperationRateLimitingPartitionType.Parameter => - context.Parameter ?? throw new AbpException( - $"OperationRateLimitingContext.Parameter is required for policy '{PolicyName}' (PartitionByParameter)."), - - OperationRateLimitingPartitionType.CurrentUser => - CurrentUser.Id?.ToString() - ?? throw new AbpException( - $"Current user is not authenticated. Policy '{PolicyName}' requires PartitionByCurrentUser. " + - "Use PartitionByParameter() if you need to specify the user ID explicitly."), - - OperationRateLimitingPartitionType.CurrentTenant => - CurrentTenant.Id?.ToString() - ?? HostTenantKey, - - OperationRateLimitingPartitionType.ClientIp => - WebClientInfoProvider.ClientIpAddress - ?? throw new AbpException( - $"Client IP address could not be determined. Policy '{PolicyName}' requires PartitionByClientIp. " + - "Ensure IWebClientInfoProvider is properly configured or use PartitionByParameter() to pass the IP explicitly."), - - OperationRateLimitingPartitionType.Email => - context.Parameter - ?? CurrentUser.Email - ?? throw new AbpException( - $"Email is required for policy '{PolicyName}' (PartitionByEmail). Provide it via context.Parameter or ensure the user has an email."), - - OperationRateLimitingPartitionType.PhoneNumber => - context.Parameter - ?? CurrentUser.PhoneNumber - ?? throw new AbpException( - $"Phone number is required for policy '{PolicyName}' (PartitionByPhoneNumber). Provide it via context.Parameter or ensure the user has a phone number."), - - OperationRateLimitingPartitionType.Custom => - await ResolveCustomPartitionKeyAsync(context), - - _ => throw new AbpException($"Unknown partition type: {Definition.PartitionType}") - }; - } - - protected virtual async Task ResolveCustomPartitionKeyAsync(OperationRateLimitingContext context) - { - var key = await Definition.CustomPartitionKeyResolver!(context); - if (string.IsNullOrEmpty(key)) - { - throw new AbpException( - $"Custom partition key resolver returned null or empty for policy '{PolicyName}'. " + - "The resolver must return a non-empty string."); - } - return key; - } - - protected virtual string BuildStoreKey(string partitionKey) - { - // Stable rule descriptor based on content so reordering rules does not change the key. - // Changing Duration or MaxCount intentionally resets counters for that rule. - var ruleKey = $"{(long)Definition.Duration.TotalSeconds}_{Definition.MaxCount}_{(int)Definition.PartitionType}"; - - // Tenant isolation is opt-in via WithMultiTenancy() on the rule builder. - // When not set, the key is global (shared across all tenants). - if (!Definition.IsMultiTenant) - { - return $"orl:{PolicyName}:{ruleKey}:{partitionKey}"; - } - - var tenantId = CurrentTenant.Id.HasValue ? CurrentTenant.Id.Value.ToString() : HostTenantKey; - return $"orl:t:{tenantId}:{PolicyName}:{ruleKey}:{partitionKey}"; - } - - protected virtual OperationRateLimitingRuleResult ToRuleResult(OperationRateLimitingStoreResult storeResult) - { - return new OperationRateLimitingRuleResult - { - RuleName = $"{PolicyName}:Rule[{(long)Definition.Duration.TotalSeconds}s,{Definition.MaxCount},{Definition.PartitionType}]", - IsAllowed = storeResult.IsAllowed, - CurrentCount = storeResult.CurrentCount, - RemainingCount = storeResult.MaxCount - storeResult.CurrentCount, - MaxCount = storeResult.MaxCount, - RetryAfter = storeResult.RetryAfter, - WindowDuration = Definition.Duration - }; - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs deleted file mode 100644 index 1bb42a1727..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Threading.Tasks; - -namespace Volo.Abp.OperationRateLimiting; - -public interface IOperationRateLimitingRule -{ - Task AcquireAsync(OperationRateLimitingContext context); - - Task CheckAsync(OperationRateLimitingContext context); - - Task ResetAsync(OperationRateLimitingContext context); -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs deleted file mode 100644 index d9f13b41d1..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Options; -using Volo.Abp.Caching; -using Volo.Abp.DependencyInjection; -using Volo.Abp.DistributedLocking; -using Volo.Abp.Timing; - -namespace Volo.Abp.OperationRateLimiting; - -public class DistributedCacheOperationRateLimitingStore : IOperationRateLimitingStore, ITransientDependency -{ - protected IDistributedCache Cache { get; } - protected IClock Clock { get; } - protected IAbpDistributedLock DistributedLock { get; } - protected AbpOperationRateLimitingOptions Options { get; } - - public DistributedCacheOperationRateLimitingStore( - IDistributedCache cache, - IClock clock, - IAbpDistributedLock distributedLock, - IOptions options) - { - Cache = cache; - Clock = clock; - DistributedLock = distributedLock; - Options = options.Value; - } - - public virtual async Task IncrementAsync( - string key, TimeSpan duration, int maxCount) - { - if (maxCount <= 0) - { - return new OperationRateLimitingStoreResult - { - IsAllowed = false, - CurrentCount = 0, - MaxCount = maxCount, - RetryAfter = null - }; - } - - await using (var handle = await DistributedLock.TryAcquireAsync( - $"OperationRateLimiting:{key}", Options.LockTimeout)) - { - if (handle == null) - { - throw new AbpException( - "Could not acquire distributed lock for operation rate limit. " + - "This is an infrastructure issue, not a rate limit violation."); - } - - var cacheItem = await Cache.GetAsync(key); - var now = new DateTimeOffset(Clock.Now.ToUniversalTime()); - - if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration)) - { - cacheItem = new OperationRateLimitingCacheItem { Count = 1, WindowStart = now }; - await Cache.SetAsync(key, cacheItem, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = duration - }); - - return new OperationRateLimitingStoreResult - { - IsAllowed = true, - CurrentCount = 1, - MaxCount = maxCount - }; - } - - if (cacheItem.Count >= maxCount) - { - var retryAfter = cacheItem.WindowStart.Add(duration) - now; - return new OperationRateLimitingStoreResult - { - IsAllowed = false, - CurrentCount = cacheItem.Count, - MaxCount = maxCount, - RetryAfter = retryAfter - }; - } - - cacheItem.Count++; - var expiration = cacheItem.WindowStart.Add(duration) - now; - await Cache.SetAsync(key, cacheItem, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = expiration > TimeSpan.Zero ? expiration : duration - }); - - return new OperationRateLimitingStoreResult - { - IsAllowed = true, - CurrentCount = cacheItem.Count, - MaxCount = maxCount - }; - } - } - - public virtual async Task GetAsync( - string key, TimeSpan duration, int maxCount) - { - if (maxCount <= 0) - { - return new OperationRateLimitingStoreResult - { - IsAllowed = false, - CurrentCount = 0, - MaxCount = maxCount, - RetryAfter = null - }; - } - - var cacheItem = await Cache.GetAsync(key); - var now = new DateTimeOffset(Clock.Now.ToUniversalTime()); - - if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration)) - { - return new OperationRateLimitingStoreResult - { - IsAllowed = true, - CurrentCount = 0, - MaxCount = maxCount - }; - } - - if (cacheItem.Count >= maxCount) - { - var retryAfter = cacheItem.WindowStart.Add(duration) - now; - return new OperationRateLimitingStoreResult - { - IsAllowed = false, - CurrentCount = cacheItem.Count, - MaxCount = maxCount, - RetryAfter = retryAfter - }; - } - - return new OperationRateLimitingStoreResult - { - IsAllowed = true, - CurrentCount = cacheItem.Count, - MaxCount = maxCount - }; - } - - public virtual async Task ResetAsync(string key) - { - await Cache.RemoveAsync(key); - } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs deleted file mode 100644 index 049fa35b0c..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace Volo.Abp.OperationRateLimiting; - -public interface IOperationRateLimitingStore -{ - Task IncrementAsync(string key, TimeSpan duration, int maxCount); - - Task GetAsync(string key, TimeSpan duration, int maxCount); - - Task ResetAsync(string key); -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs deleted file mode 100644 index 2d92d8578e..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Volo.Abp.Caching; -using Volo.Abp.MultiTenancy; - -namespace Volo.Abp.OperationRateLimiting; - -[CacheName("OperationRateLimiting")] -[IgnoreMultiTenancy] -public class OperationRateLimitingCacheItem -{ - public int Count { get; set; } - - public DateTimeOffset WindowStart { get; set; } -} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs deleted file mode 100644 index caa2bd640d..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingStoreResult -{ - public bool IsAllowed { get; set; } - - public int CurrentCount { get; set; } - - public int MaxCount { get; set; } - - public TimeSpan? RetryAfter { get; set; } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj deleted file mode 100644 index a9d2d9ee36..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - net10.0 - - - - - - - - - - - - diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs deleted file mode 100644 index 9d88c5e57d..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using Shouldly; -using Xunit; - -namespace Volo.Abp.OperationRateLimiting; - -public class AbpOperationRateLimitingException_Tests -{ - [Fact] - public void Should_Set_HttpStatusCode_To_429() - { - var result = new OperationRateLimitingResult - { - IsAllowed = false, - MaxCount = 3, - CurrentCount = 3, - RemainingCount = 0, - RetryAfter = TimeSpan.FromMinutes(15) - }; - - var exception = new AbpOperationRateLimitingException("TestPolicy", result); - - exception.HttpStatusCode.ShouldBe(429); - } - - [Fact] - public void Should_Use_ExceedLimit_Code_When_RetryAfter_Is_Set() - { - var result = new OperationRateLimitingResult - { - IsAllowed = false, - MaxCount = 3, - CurrentCount = 3, - RemainingCount = 0, - RetryAfter = TimeSpan.FromMinutes(5) - }; - - var exception = new AbpOperationRateLimitingException("TestPolicy", result); - - exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); - } - - [Fact] - public void Should_Use_ExceedLimitPermanently_Code_When_RetryAfter_Is_Null() - { - var result = new OperationRateLimitingResult - { - IsAllowed = false, - MaxCount = 0, - CurrentCount = 0, - RemainingCount = 0, - RetryAfter = null - }; - - var exception = new AbpOperationRateLimitingException("TestPolicy", result); - - exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently); - } - - [Fact] - public void Should_Set_Custom_ErrorCode() - { - var result = new OperationRateLimitingResult - { - IsAllowed = false, - MaxCount = 3, - CurrentCount = 3, - RemainingCount = 0 - }; - - var exception = new AbpOperationRateLimitingException("TestPolicy", result, "App:Custom:Error"); - - exception.Code.ShouldBe("App:Custom:Error"); - } - - [Fact] - public void Should_Include_Data_Properties() - { - var result = new OperationRateLimitingResult - { - IsAllowed = false, - MaxCount = 3, - CurrentCount = 3, - RemainingCount = 0, - RetryAfter = TimeSpan.FromMinutes(15), - WindowDuration = TimeSpan.FromHours(1) - }; - - var exception = new AbpOperationRateLimitingException("TestPolicy", result); - - exception.Data["PolicyName"].ShouldBe("TestPolicy"); - exception.Data["MaxCount"].ShouldBe(3); - exception.Data["CurrentCount"].ShouldBe(3); - exception.Data["RemainingCount"].ShouldBe(0); - exception.Data["RetryAfterSeconds"].ShouldBe(900); - exception.Data["RetryAfterMinutes"].ShouldBe(15); - exception.Data["WindowDurationSeconds"].ShouldBe(3600); - } - - [Fact] - public void Should_Store_PolicyName_And_Result() - { - var result = new OperationRateLimitingResult - { - IsAllowed = false, - MaxCount = 5, - CurrentCount = 5, - RemainingCount = 0, - RetryAfter = TimeSpan.FromHours(1) - }; - - var exception = new AbpOperationRateLimitingException("MyPolicy", result); - - exception.PolicyName.ShouldBe("MyPolicy"); - exception.Result.ShouldBeSameAs(result); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs deleted file mode 100644 index b36b9778cd..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Volo.Abp.Autofac; -using Volo.Abp.ExceptionHandling; -using Volo.Abp.Modularity; - -namespace Volo.Abp.OperationRateLimiting; - -/// -/// A mock store that simulates a multi-rule Phase 2 race condition: -/// - GetAsync always reports quota available (Phase 1 passes for all rules). -/// - IncrementAsync succeeds for the first call, fails on the second call -/// (simulating a concurrent race on Rule2), and tracks total increment calls -/// so tests can verify that Rule3 was never incremented (early break). -/// -internal class MultiRuleRaceConditionSimulatorStore : IOperationRateLimitingStore -{ - private int _incrementCallCount; - - /// - /// Total number of IncrementAsync calls made. - /// - public int IncrementCallCount => _incrementCallCount; - - public Task GetAsync(string key, TimeSpan duration, int maxCount) - { - return Task.FromResult(new OperationRateLimitingStoreResult - { - IsAllowed = true, - CurrentCount = 0, - MaxCount = maxCount - }); - } - - public Task IncrementAsync(string key, TimeSpan duration, int maxCount) - { - var callIndex = Interlocked.Increment(ref _incrementCallCount); - - if (callIndex == 2) - { - // Second rule: simulate concurrent race - another request consumed the last slot. - return Task.FromResult(new OperationRateLimitingStoreResult - { - IsAllowed = false, - CurrentCount = maxCount, - MaxCount = maxCount, - RetryAfter = duration - }); - } - - // First rule (and any others if early break fails): succeed. - return Task.FromResult(new OperationRateLimitingStoreResult - { - IsAllowed = true, - CurrentCount = 1, - MaxCount = maxCount - }); - } - - public Task ResetAsync(string key) - { - return Task.CompletedTask; - } -} - -[DependsOn( - typeof(AbpOperationRateLimitingModule), - typeof(AbpExceptionHandlingModule), - typeof(AbpTestBaseModule), - typeof(AbpAutofacModule) -)] -public class AbpOperationRateLimitingPhase2EarlyBreakTestModule : AbpModule -{ - public override void ConfigureServices(ServiceConfigurationContext context) - { - context.Services.Replace( - ServiceDescriptor.Singleton()); - - Configure(options => - { - // 3-rule composite policy: all PartitionByParameter with different durations - // so they generate unique cache keys and don't trigger duplicate rule validation. - options.AddPolicy("TestMultiRuleRacePolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(2), maxCount: 5) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(3), maxCount: 5) - .PartitionByParameter()); - }); - }); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs deleted file mode 100644 index c60381c774..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Volo.Abp.Autofac; -using Volo.Abp.ExceptionHandling; -using Volo.Abp.Modularity; - -namespace Volo.Abp.OperationRateLimiting; - -/// -/// A mock store that simulates a concurrent race condition: -/// - GetAsync always says the quota is available (Phase 1 checks pass). -/// - IncrementAsync always says the quota is exhausted (Phase 2 finds another request consumed it). -/// -internal class RaceConditionSimulatorStore : IOperationRateLimitingStore -{ - public Task GetAsync(string key, TimeSpan duration, int maxCount) - { - return Task.FromResult(new OperationRateLimitingStoreResult - { - IsAllowed = true, - CurrentCount = 0, - MaxCount = maxCount - }); - } - - public Task IncrementAsync(string key, TimeSpan duration, int maxCount) - { - // Simulate: between Phase 1 and Phase 2 another concurrent request consumed the last slot. - return Task.FromResult(new OperationRateLimitingStoreResult - { - IsAllowed = false, - CurrentCount = maxCount, - MaxCount = maxCount, - RetryAfter = duration - }); - } - - public Task ResetAsync(string key) - { - return Task.CompletedTask; - } -} - -[DependsOn( - typeof(AbpOperationRateLimitingModule), - typeof(AbpExceptionHandlingModule), - typeof(AbpTestBaseModule), - typeof(AbpAutofacModule) -)] -public class AbpOperationRateLimitingPhase2RaceTestModule : AbpModule -{ - public override void ConfigureServices(ServiceConfigurationContext context) - { - context.Services.Replace( - ServiceDescriptor.Transient()); - - Configure(options => - { - options.AddPolicy("TestRacePolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByParameter(); - }); - }); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs deleted file mode 100644 index cd436546f1..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using Volo.Abp.AspNetCore.WebClientInfo; -using Volo.Abp.Autofac; -using Volo.Abp.ExceptionHandling; -using Volo.Abp.Modularity; - -namespace Volo.Abp.OperationRateLimiting; - -[DependsOn( - typeof(AbpOperationRateLimitingModule), - typeof(AbpExceptionHandlingModule), - typeof(AbpTestBaseModule), - typeof(AbpAutofacModule) -)] -public class AbpOperationRateLimitingTestModule : AbpModule -{ - public override void ConfigureServices(ServiceConfigurationContext context) - { - var mockWebClientInfoProvider = Substitute.For(); - mockWebClientInfoProvider.ClientIpAddress.Returns("127.0.0.1"); - context.Services.AddSingleton(mockWebClientInfoProvider); - - Configure(options => - { - options.AddPolicy("TestSimple", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByParameter(); - }); - - options.AddPolicy("TestUserBased", policy => - { - policy.WithFixedWindow(TimeSpan.FromMinutes(30), maxCount: 5) - .PartitionByCurrentUser(); - }); - - options.AddPolicy("TestComposite", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromDays(1), maxCount: 10) - .PartitionByCurrentUser()); - }); - - options.AddPolicy("TestCustomErrorCode", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) - .PartitionByParameter() - .WithErrorCode("Test:CustomError"); - }); - - options.AddPolicy("TestTenantBased", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByCurrentTenant(); - }); - - options.AddPolicy("TestClientIp", policy => - { - policy.WithFixedWindow(TimeSpan.FromMinutes(15), maxCount: 10) - .PartitionByClientIp(); - }); - - options.AddPolicy("TestEmailBased", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByEmail(); - }); - - options.AddPolicy("TestPhoneNumberBased", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByPhoneNumber(); - }); - - // Composite where Rule2 triggers before Rule1 (to test no-wasted-increment) - options.AddPolicy("TestCompositeRule2First", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) - .PartitionByCurrentUser()); - }); - - // Composite: ByParameter + ByClientIp (different partition types, no auth) - options.AddPolicy("TestCompositeParamIp", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByClientIp()); - }); - - // Composite: Triple - ByParameter + ByCurrentUser + ByClientIp - options.AddPolicy("TestCompositeTriple", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 4) - .PartitionByCurrentUser()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByClientIp()); - }); - - // Fix #6: policy where both rules block simultaneously with different RetryAfter durations. - // Used to verify that Phase 1 checks ALL rules and reports the maximum RetryAfter. - // Rule0: 5-minute window → RetryAfter ~5 min when full - // Rule1: 2-hour window → RetryAfter ~2 hr when full - options.AddPolicy("TestCompositeMaxRetryAfter", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 1) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(2), maxCount: 1) - .PartitionByParameter()); - }); - - // Fix #6: policy where only Rule0 blocks but Rule1 is still within limit. - // Used to verify that RuleResults contains all rules, not just the blocking one. - options.AddPolicy("TestCompositePartialBlock", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 1) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) - .PartitionByParameter()); - }); - - // Ban policy: maxCount=0 should always deny - options.AddPolicy("TestBanPolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 0) - .PartitionByParameter(); - }); - - // Custom resolver: combines Parameter + a static prefix to simulate multi-value key - options.AddPolicy("TestCustomResolver", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) - .PartitionBy(ctx => Task.FromResult($"action:{ctx.Parameter}")); - }); - - // Custom resolver returning null - should throw - options.AddPolicy("TestCustomResolverNull", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) - .PartitionBy(ctx => Task.FromResult(null!)); - }); - - // Multi-tenant: ByParameter with tenant isolation - same param, different tenants = different counters - options.AddPolicy("TestMultiTenantByParameter", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) - .WithMultiTenancy() - .PartitionByParameter(); - }); - - // Multi-tenant: ByClientIp (global) - same IP, different tenants = same counter - options.AddPolicy("TestMultiTenantByClientIp", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) - .PartitionByClientIp(); - }); - }); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs deleted file mode 100644 index b612419e48..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Threading.Tasks; -using Shouldly; -using Xunit; - -namespace Volo.Abp.OperationRateLimiting; - -public class DistributedCacheOperationRateLimitingStore_Tests : OperationRateLimitingTestBase -{ - private readonly IOperationRateLimitingStore _store; - - public DistributedCacheOperationRateLimitingStore_Tests() - { - _store = GetRequiredService(); - } - - [Fact] - public async Task Should_Create_New_Window_On_First_Request() - { - var key = $"store-new-{Guid.NewGuid()}"; - var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); - - result.IsAllowed.ShouldBeTrue(); - result.CurrentCount.ShouldBe(1); - result.MaxCount.ShouldBe(5); - result.RetryAfter.ShouldBeNull(); - } - - [Fact] - public async Task Should_Increment_Within_Window() - { - var key = $"store-incr-{Guid.NewGuid()}"; - - await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); - var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); - - result.IsAllowed.ShouldBeTrue(); - result.CurrentCount.ShouldBe(2); - } - - [Fact] - public async Task Should_Reject_When_MaxCount_Reached() - { - var key = $"store-max-{Guid.NewGuid()}"; - - await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - - result.IsAllowed.ShouldBeFalse(); - result.CurrentCount.ShouldBe(2); - result.RetryAfter.ShouldNotBeNull(); - result.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); - } - - [Fact] - public async Task Should_Reset_Counter() - { - var key = $"store-reset-{Guid.NewGuid()}"; - - await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - - // At max now - var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - result.IsAllowed.ShouldBeFalse(); - - // Reset - await _store.ResetAsync(key); - - // Should be allowed again - result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - result.IsAllowed.ShouldBeTrue(); - result.CurrentCount.ShouldBe(1); - } - - [Fact] - public async Task Should_Get_Status_Without_Incrementing() - { - var key = $"store-get-{Guid.NewGuid()}"; - - await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); - - var result = await _store.GetAsync(key, TimeSpan.FromHours(1), 5); - result.IsAllowed.ShouldBeTrue(); - result.CurrentCount.ShouldBe(1); - - // Get again should still be 1 (no increment) - result = await _store.GetAsync(key, TimeSpan.FromHours(1), 5); - result.CurrentCount.ShouldBe(1); - } - - [Fact] - public async Task Should_Not_Isolate_By_Tenant_At_Store_Level() - { - // Tenant isolation is now handled at the rule level (BuildStoreKey), - // not at the store level. The store treats keys as opaque strings. - var key = $"store-tenant-{Guid.NewGuid()}"; - - await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - - var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); - result.IsAllowed.ShouldBeFalse(); - - // Same key, same counter regardless of tenant context - result = await _store.GetAsync(key, TimeSpan.FromHours(1), 2); - result.IsAllowed.ShouldBeFalse(); - result.CurrentCount.ShouldBe(2); - } - - [Fact] - public async Task Should_Deny_Immediately_When_MaxCount_Is_Zero_Increment() - { - var key = $"store-zero-incr-{Guid.NewGuid()}"; - var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 0); - - result.IsAllowed.ShouldBeFalse(); - result.CurrentCount.ShouldBe(0); - result.MaxCount.ShouldBe(0); - result.RetryAfter.ShouldBeNull(); - } - - [Fact] - public async Task Should_Deny_Immediately_When_MaxCount_Is_Zero_Get() - { - var key = $"store-zero-get-{Guid.NewGuid()}"; - var result = await _store.GetAsync(key, TimeSpan.FromHours(1), 0); - - result.IsAllowed.ShouldBeFalse(); - result.CurrentCount.ShouldBe(0); - result.MaxCount.ShouldBe(0); - result.RetryAfter.ShouldBeNull(); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs deleted file mode 100644 index fce15fa466..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System; -using System.Threading.Tasks; -using Shouldly; -using Volo.Abp.Testing; -using Xunit; - -namespace Volo.Abp.OperationRateLimiting; - -/// -/// Tests for Fix #6: Phase 1 in CheckAsync now checks ALL rules before throwing, -/// so RetryAfter is the maximum across all blocking rules and RuleResults is complete. -/// -public class OperationRateLimitingCheckerPhase1_Tests : OperationRateLimitingTestBase -{ - private readonly IOperationRateLimitingChecker _checker; - - public OperationRateLimitingCheckerPhase1_Tests() - { - _checker = GetRequiredService(); - } - - [Fact] - public async Task Should_Report_Max_RetryAfter_When_Multiple_Rules_Block() - { - // TestCompositeMaxRetryAfter: Rule0 (5-min window, max=1), Rule1 (2-hr window, max=1) - // Both rules use PartitionByParameter with the same key, so one request exhausts both. - var param = $"max-retry-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // First request: both rules go from 0 to 1 (exhausted, since maxCount=1) - await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); - - // Second request: both Rule0 and Rule1 are blocking. - // Phase 1 checks all rules → RetryAfter must be the larger one (~2 hours). - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); - }); - - // RetryAfter should be at least 1 hour (i.e., from Rule1's 2-hour window, not Rule0's 5-min window) - exception.Result.RetryAfter.ShouldNotBeNull(); - exception.Result.RetryAfter!.Value.ShouldBeGreaterThan(TimeSpan.FromHours(1)); - } - - [Fact] - public async Task Should_Include_All_Rules_In_RuleResults_When_Multiple_Rules_Block() - { - var param = $"all-rules-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // Exhaust both rules - await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); - }); - - // Both rules must appear in RuleResults (not just the first blocking one) - exception.Result.RuleResults.ShouldNotBeNull(); - exception.Result.RuleResults!.Count.ShouldBe(2); - exception.Result.RuleResults[0].IsAllowed.ShouldBeFalse(); - exception.Result.RuleResults[1].IsAllowed.ShouldBeFalse(); - } - - [Fact] - public async Task Should_Include_Non_Blocking_Rules_In_RuleResults() - { - // TestCompositePartialBlock: Rule0 (max=1) blocks, Rule1 (max=100) is still within limit. - // RuleResults must contain BOTH rules so callers get the full picture. - var param = $"partial-block-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // Exhaust only Rule0 (max=1) - await _checker.CheckAsync("TestCompositePartialBlock", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCompositePartialBlock", context); - }); - - exception.Result.RuleResults.ShouldNotBeNull(); - exception.Result.RuleResults!.Count.ShouldBe(2); - - // Rule0 is blocking - exception.Result.RuleResults[0].IsAllowed.ShouldBeFalse(); - exception.Result.RuleResults[0].MaxCount.ShouldBe(1); - - // Rule1 is still allowed (only 1/100 used), but is still present in results - exception.Result.RuleResults[1].IsAllowed.ShouldBeTrue(); - exception.Result.RuleResults[1].MaxCount.ShouldBe(100); - exception.Result.RuleResults[1].RemainingCount.ShouldBe(99); - - // The overall RetryAfter comes only from the blocking Rule0 - exception.Result.RetryAfter.ShouldNotBeNull(); - exception.Result.RetryAfter!.Value.TotalMinutes.ShouldBeLessThan(61); // ~1 hour from Rule0 - } -} - -/// -/// Tests for Phase 2 early break: when a multi-rule policy encounters a race condition -/// in Phase 2 (Rule2 fails), Rule3 should NOT be incremented. -/// Uses a mock store where IncrementAsync fails on the 2nd call. -/// -public class OperationRateLimitingCheckerPhase2EarlyBreak_Tests - : AbpIntegratedTest -{ - protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) - { - options.UseAutofac(); - } - - [Fact] - public async Task Should_Not_Increment_Remaining_Rules_After_Phase2_Failure() - { - // 3-rule policy. Mock store: Rule1 increment succeeds, Rule2 increment fails (race), - // Rule3 should NOT be incremented due to early break. - var checker = GetRequiredService(); - var store = (MultiRuleRaceConditionSimulatorStore)GetRequiredService(); - var context = new OperationRateLimitingContext { Parameter = "early-break-test" }; - - var exception = await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestMultiRuleRacePolicy", context); - }); - - exception.PolicyName.ShouldBe("TestMultiRuleRacePolicy"); - exception.Result.IsAllowed.ShouldBeFalse(); - - // Key assertion: only 2 IncrementAsync calls were made (Rule1 + Rule2). - // Rule3 was skipped (used CheckAsync instead) due to early break. - store.IncrementCallCount.ShouldBe(2); - } - - [Fact] - public async Task Should_Include_All_Rule_Results_Despite_Early_Break() - { - // Even with early break, the aggregated result should contain all 3 rules - // (Rule3 via CheckAsync instead of AcquireAsync). - var checker = GetRequiredService(); - var context = new OperationRateLimitingContext { Parameter = $"all-results-{Guid.NewGuid()}" }; - - var exception = await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestMultiRuleRacePolicy", context); - }); - - exception.Result.RuleResults.ShouldNotBeNull(); - exception.Result.RuleResults!.Count.ShouldBe(3); - } -} - -/// -/// Tests for Fix #1: Phase 2 in CheckAsync now checks the result of AcquireAsync. -/// Uses a mock store that simulates a concurrent race condition: -/// GetAsync (Phase 1) always reports quota available, but IncrementAsync (Phase 2) returns denied. -/// -public class OperationRateLimitingCheckerPhase2Race_Tests - : AbpIntegratedTest -{ - protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) - { - options.UseAutofac(); - } - - [Fact] - public async Task Should_Throw_When_Phase2_Increment_Returns_Denied_Due_To_Race() - { - // The mock store always returns IsAllowed=true in GetAsync (Phase 1 passes) - // but always returns IsAllowed=false in IncrementAsync (simulates concurrent exhaustion). - // Before Fix #1, CheckAsync would silently succeed. After the fix it must throw. - var checker = GetRequiredService(); - var context = new OperationRateLimitingContext { Parameter = "race-test" }; - - var exception = await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestRacePolicy", context); - }); - - exception.PolicyName.ShouldBe("TestRacePolicy"); - exception.Result.IsAllowed.ShouldBeFalse(); - exception.HttpStatusCode.ShouldBe(429); - } - - [Fact] - public async Task IsAllowedAsync_Should_Not_Be_Affected_By_Phase2_Fix() - { - // IsAllowedAsync is read-only and does not call IncrementAsync, - // so it should not be affected by the mock store's deny-on-increment behavior. - var checker = GetRequiredService(); - var context = new OperationRateLimitingContext { Parameter = "is-allowed-race" }; - - // Should return true because GetAsync always returns allowed in the mock store - var allowed = await checker.IsAllowedAsync("TestRacePolicy", context); - allowed.ShouldBeTrue(); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs deleted file mode 100644 index fd3d9f4214..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs +++ /dev/null @@ -1,798 +0,0 @@ -using System; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Shouldly; -using Volo.Abp.Security.Claims; -using Xunit; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase -{ - private readonly IOperationRateLimitingChecker _checker; - - public OperationRateLimitingChecker_Tests() - { - _checker = GetRequiredService(); - } - - [Fact] - public async Task Should_Allow_Within_Limit() - { - var context = new OperationRateLimitingContext { Parameter = "test@example.com" }; - - // Should not throw for 3 requests (max is 3) - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - } - - [Fact] - public async Task Should_Reject_When_Exceeded() - { - var param = $"exceed-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestSimple", context); - }); - - exception.PolicyName.ShouldBe("TestSimple"); - exception.Result.IsAllowed.ShouldBeFalse(); - exception.HttpStatusCode.ShouldBe(429); - exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); - } - - [Fact] - public async Task Should_Return_Correct_RemainingCount() - { - var param = $"remaining-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - var status = await _checker.GetStatusAsync("TestSimple", context); - status.IsAllowed.ShouldBeTrue(); - status.RemainingCount.ShouldBe(3); - status.CurrentCount.ShouldBe(0); - - // Increment once - await _checker.CheckAsync("TestSimple", context); - - status = await _checker.GetStatusAsync("TestSimple", context); - status.IsAllowed.ShouldBeTrue(); - status.RemainingCount.ShouldBe(2); - status.CurrentCount.ShouldBe(1); - } - - [Fact] - public async Task Should_Return_Correct_RetryAfter() - { - var param = $"retry-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestSimple", context); - }); - - exception.Result.RetryAfter.ShouldNotBeNull(); - exception.Result.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); - } - - [Fact] - public async Task Should_Handle_Composite_Policy_All_Pass() - { - var userId = Guid.NewGuid(); - - using (var scope = ServiceProvider.CreateScope()) - { - var principalAccessor = scope.ServiceProvider.GetRequiredService(); - var claimsPrincipal = CreateClaimsPrincipal(userId); - - using (principalAccessor.Change(claimsPrincipal)) - { - var checker = scope.ServiceProvider.GetRequiredService(); - var context = new OperationRateLimitingContext { Parameter = $"composite-{Guid.NewGuid()}" }; - - // Should pass: both rules within limits - await checker.CheckAsync("TestComposite", context); - await checker.CheckAsync("TestComposite", context); - await checker.CheckAsync("TestComposite", context); - } - } - } - - [Fact] - public async Task Should_Reject_Composite_Policy_When_Any_Rule_Exceeds() - { - var userId = Guid.NewGuid(); - - using (var scope = ServiceProvider.CreateScope()) - { - var principalAccessor = scope.ServiceProvider.GetRequiredService(); - var claimsPrincipal = CreateClaimsPrincipal(userId); - - using (principalAccessor.Change(claimsPrincipal)) - { - var checker = scope.ServiceProvider.GetRequiredService(); - var param = $"composite-reject-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await checker.CheckAsync("TestComposite", context); - await checker.CheckAsync("TestComposite", context); - await checker.CheckAsync("TestComposite", context); - - // 4th request: Rule1 (max 3 per hour by parameter) should fail - var exception = await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestComposite", context); - }); - - exception.PolicyName.ShouldBe("TestComposite"); - } - } - } - - [Fact] - public async Task Should_Reset_Counter() - { - var param = $"reset-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - // Should be at limit - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestSimple", context); - }); - - // Reset - await _checker.ResetAsync("TestSimple", context); - - // Should be allowed again - await _checker.CheckAsync("TestSimple", context); - } - - [Fact] - public async Task Should_Use_Custom_ErrorCode() - { - var param = $"custom-error-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await _checker.CheckAsync("TestCustomErrorCode", context); - await _checker.CheckAsync("TestCustomErrorCode", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCustomErrorCode", context); - }); - - exception.Code.ShouldBe("Test:CustomError"); - } - - [Fact] - public async Task Should_Throw_For_Unknown_Policy() - { - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("NonExistentPolicy"); - }); - } - - [Fact] - public async Task Should_Skip_When_Disabled() - { - var options = GetRequiredService>(); - var originalValue = options.Value.IsEnabled; - - try - { - options.Value.IsEnabled = false; - - var param = $"disabled-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // Should pass unlimited times - for (var i = 0; i < 100; i++) - { - await _checker.CheckAsync("TestSimple", context); - } - } - finally - { - options.Value.IsEnabled = originalValue; - } - } - - [Fact] - public async Task Should_Work_With_IsAllowedAsync() - { - var param = $"is-allowed-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // IsAllowedAsync does not consume quota - (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); - (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); - - // Status should still show 0 consumed - var status = await _checker.GetStatusAsync("TestSimple", context); - status.CurrentCount.ShouldBe(0); - status.RemainingCount.ShouldBe(3); - - // Now consume all - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); - } - - [Fact] - public async Task Should_Partition_By_Different_Parameters() - { - var param1 = $"param1-{Guid.NewGuid()}"; - var param2 = $"param2-{Guid.NewGuid()}"; - - var context1 = new OperationRateLimitingContext { Parameter = param1 }; - var context2 = new OperationRateLimitingContext { Parameter = param2 }; - - // Consume all for param1 - await _checker.CheckAsync("TestSimple", context1); - await _checker.CheckAsync("TestSimple", context1); - await _checker.CheckAsync("TestSimple", context1); - - // param2 should still be allowed - await _checker.CheckAsync("TestSimple", context2); - (await _checker.IsAllowedAsync("TestSimple", context2)).ShouldBeTrue(); - } - - [Fact] - public async Task Should_Support_ExtraProperties_In_Exception_Data() - { - var param = $"extra-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext - { - Parameter = param, - ExtraProperties = - { - ["Email"] = "test@example.com", - ["UserId"] = "user-123" - } - }; - - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestSimple", context); - }); - - exception.Data["Email"].ShouldBe("test@example.com"); - exception.Data["UserId"].ShouldBe("user-123"); - exception.Data["PolicyName"].ShouldBe("TestSimple"); - exception.Data["MaxCount"].ShouldBe(3); - } - - [Fact] - public async Task Should_Partition_By_Email_Via_Parameter() - { - var email = $"email-param-{Guid.NewGuid()}@example.com"; - var context = new OperationRateLimitingContext { Parameter = email }; - - await _checker.CheckAsync("TestEmailBased", context); - await _checker.CheckAsync("TestEmailBased", context); - await _checker.CheckAsync("TestEmailBased", context); - - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestEmailBased", context); - }); - } - - [Fact] - public async Task Should_Partition_By_Email_Via_CurrentUser_Fallback() - { - var userId = Guid.NewGuid(); - - using (var scope = ServiceProvider.CreateScope()) - { - var principalAccessor = scope.ServiceProvider.GetRequiredService(); - var claimsPrincipal = CreateClaimsPrincipal(userId); - - using (principalAccessor.Change(claimsPrincipal)) - { - var checker = scope.ServiceProvider.GetRequiredService(); - - // No Parameter set, should fall back to ICurrentUser.Email - var context = new OperationRateLimitingContext(); - - await checker.CheckAsync("TestEmailBased", context); - await checker.CheckAsync("TestEmailBased", context); - await checker.CheckAsync("TestEmailBased", context); - - await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestEmailBased", context); - }); - } - } - } - - [Fact] - public async Task Should_Partition_By_PhoneNumber_Via_Parameter() - { - var phone = $"phone-param-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = phone }; - - await _checker.CheckAsync("TestPhoneNumberBased", context); - await _checker.CheckAsync("TestPhoneNumberBased", context); - await _checker.CheckAsync("TestPhoneNumberBased", context); - - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestPhoneNumberBased", context); - }); - } - - [Fact] - public async Task Should_Partition_By_PhoneNumber_Via_CurrentUser_Fallback() - { - var userId = Guid.NewGuid(); - - using (var scope = ServiceProvider.CreateScope()) - { - var principalAccessor = scope.ServiceProvider.GetRequiredService(); - var claimsPrincipal = CreateClaimsPrincipal(userId); - - using (principalAccessor.Change(claimsPrincipal)) - { - var checker = scope.ServiceProvider.GetRequiredService(); - - // No Parameter set, should fall back to ICurrentUser.PhoneNumber - var context = new OperationRateLimitingContext(); - - await checker.CheckAsync("TestPhoneNumberBased", context); - await checker.CheckAsync("TestPhoneNumberBased", context); - await checker.CheckAsync("TestPhoneNumberBased", context); - - await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestPhoneNumberBased", context); - }); - } - } - } - - [Fact] - public async Task Should_Throw_When_Email_Not_Available() - { - // No Parameter and no authenticated user - var context = new OperationRateLimitingContext(); - - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestEmailBased", context); - }); - } - - [Fact] - public async Task Should_Not_Waste_Rule1_Count_When_Rule2_Blocks() - { - // TestCompositeRule2First: Rule1 (Parameter, 5/hour), Rule2 (CurrentUser, 2/hour) - // Rule2 triggers at 2. Rule1 should NOT be incremented for blocked requests. - var userId = Guid.NewGuid(); - - using (var scope = ServiceProvider.CreateScope()) - { - var principalAccessor = scope.ServiceProvider.GetRequiredService(); - var claimsPrincipal = CreateClaimsPrincipal(userId); - - using (principalAccessor.Change(claimsPrincipal)) - { - var checker = scope.ServiceProvider.GetRequiredService(); - var param = $"no-waste-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // 2 successful requests (Rule1: 2/5, Rule2: 2/2) - await checker.CheckAsync("TestCompositeRule2First", context); - await checker.CheckAsync("TestCompositeRule2First", context); - - // 3rd request: Rule2 blocks (2/2 at max) - await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestCompositeRule2First", context); - }); - - // Verify Rule1 was NOT incremented for the blocked request - // Rule1 should still be at 2/5, not 3/5 - var status = await checker.GetStatusAsync("TestCompositeRule2First", context); - // GetStatusAsync returns the most restrictive rule (Rule2 at 2/2) - // But we can verify Rule1 by checking RuleResults - status.RuleResults.ShouldNotBeNull(); - status.RuleResults!.Count.ShouldBe(2); - - // Rule1 (index 0): should be 2/5, remaining 3 - status.RuleResults[0].RemainingCount.ShouldBe(3); - status.RuleResults[0].MaxCount.ShouldBe(5); - - // Rule2 (index 1): should be 2/2, remaining 0 - status.RuleResults[1].RemainingCount.ShouldBe(0); - status.RuleResults[1].MaxCount.ShouldBe(2); - } - } - } - - [Fact] - public async Task Should_Composite_ParamIp_Ip_Triggers_First() - { - // TestCompositeParamIp: Rule1 (Parameter, 5/hour), Rule2 (ClientIp, 3/hour) - // IP limit (3) is lower, should trigger first - var param = $"param-ip-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // 3 successful requests - await _checker.CheckAsync("TestCompositeParamIp", context); - await _checker.CheckAsync("TestCompositeParamIp", context); - await _checker.CheckAsync("TestCompositeParamIp", context); - - // 4th: IP rule blocks (3/3) - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCompositeParamIp", context); - }); - - exception.PolicyName.ShouldBe("TestCompositeParamIp"); - - // Verify counts: Rule1 should be 3/5, Rule2 should be 3/3 - var status = await _checker.GetStatusAsync("TestCompositeParamIp", context); - status.RuleResults.ShouldNotBeNull(); - status.RuleResults!.Count.ShouldBe(2); - - status.RuleResults[0].RemainingCount.ShouldBe(2); // Parameter: 3/5, remaining 2 - status.RuleResults[0].MaxCount.ShouldBe(5); - status.RuleResults[1].RemainingCount.ShouldBe(0); // IP: 3/3, remaining 0 - status.RuleResults[1].MaxCount.ShouldBe(3); - } - - [Fact] - public async Task Should_Composite_ParamIp_Different_Params_Share_Ip() - { - // Different parameters should have independent Rule1 counters - // but share the same Rule2 (IP) counter - var param1 = $"share-ip-1-{Guid.NewGuid()}"; - var param2 = $"share-ip-2-{Guid.NewGuid()}"; - var context1 = new OperationRateLimitingContext { Parameter = param1 }; - var context2 = new OperationRateLimitingContext { Parameter = param2 }; - - // 2 requests with param1 - await _checker.CheckAsync("TestCompositeParamIp", context1); - await _checker.CheckAsync("TestCompositeParamIp", context1); - - // 1 request with param2 (IP counter is now at 3/3) - await _checker.CheckAsync("TestCompositeParamIp", context2); - - // 4th request with param2: IP rule blocks (3/3 from combined) - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCompositeParamIp", context2); - }); - - // param1 Rule1 should be at 2/5 - var status1 = await _checker.GetStatusAsync("TestCompositeParamIp", context1); - status1.RuleResults![0].RemainingCount.ShouldBe(3); // Parameter: 2/5 - status1.RuleResults[0].MaxCount.ShouldBe(5); - - // param2 Rule1 should be at 1/5 - var status2 = await _checker.GetStatusAsync("TestCompositeParamIp", context2); - status2.RuleResults![0].RemainingCount.ShouldBe(4); // Parameter: 1/5 - status2.RuleResults[0].MaxCount.ShouldBe(5); - } - - [Fact] - public async Task Should_Composite_Triple_Lowest_Limit_Triggers_First() - { - // TestCompositeTriple: Rule1 (Parameter, 5/hour), Rule2 (User, 4/hour), Rule3 (IP, 3/hour) - // IP limit (3) is lowest, should trigger first - var userId = Guid.NewGuid(); - - using (var scope = ServiceProvider.CreateScope()) - { - var principalAccessor = scope.ServiceProvider.GetRequiredService(); - var claimsPrincipal = CreateClaimsPrincipal(userId); - - using (principalAccessor.Change(claimsPrincipal)) - { - var checker = scope.ServiceProvider.GetRequiredService(); - var param = $"triple-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // 3 successful requests - await checker.CheckAsync("TestCompositeTriple", context); - await checker.CheckAsync("TestCompositeTriple", context); - await checker.CheckAsync("TestCompositeTriple", context); - - // 4th: IP rule blocks (3/3) - await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestCompositeTriple", context); - }); - - // Verify all three rules - var status = await checker.GetStatusAsync("TestCompositeTriple", context); - status.RuleResults.ShouldNotBeNull(); - status.RuleResults!.Count.ShouldBe(3); - - status.RuleResults[0].RemainingCount.ShouldBe(2); // Parameter: 3/5 - status.RuleResults[0].MaxCount.ShouldBe(5); - status.RuleResults[1].RemainingCount.ShouldBe(1); // User: 3/4 - status.RuleResults[1].MaxCount.ShouldBe(4); - status.RuleResults[2].RemainingCount.ShouldBe(0); // IP: 3/3 - status.RuleResults[2].MaxCount.ShouldBe(3); - } - } - } - - [Fact] - public async Task Should_Composite_Triple_No_Wasted_Increment_On_Block() - { - // When IP (Rule3) blocks, Rule1 and Rule2 should NOT be incremented - var userId = Guid.NewGuid(); - - using (var scope = ServiceProvider.CreateScope()) - { - var principalAccessor = scope.ServiceProvider.GetRequiredService(); - var claimsPrincipal = CreateClaimsPrincipal(userId); - - using (principalAccessor.Change(claimsPrincipal)) - { - var checker = scope.ServiceProvider.GetRequiredService(); - var param = $"triple-nowaste-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // 3 successful requests (all rules increment to 3) - await checker.CheckAsync("TestCompositeTriple", context); - await checker.CheckAsync("TestCompositeTriple", context); - await checker.CheckAsync("TestCompositeTriple", context); - - // Attempt 3 more blocked requests - for (var i = 0; i < 3; i++) - { - await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestCompositeTriple", context); - }); - } - - // Verify Rule1 and Rule2 were NOT incremented beyond 3 - var status = await checker.GetStatusAsync("TestCompositeTriple", context); - status.RuleResults![0].RemainingCount.ShouldBe(2); // Parameter: still 3/5 - status.RuleResults[1].RemainingCount.ShouldBe(1); // User: still 3/4 - status.RuleResults[2].RemainingCount.ShouldBe(0); // IP: still 3/3 - } - } - } - - [Fact] - public async Task Should_Composite_Reset_All_Rules() - { - // Verify reset clears all rules in a composite policy - var userId = Guid.NewGuid(); - - using (var scope = ServiceProvider.CreateScope()) - { - var principalAccessor = scope.ServiceProvider.GetRequiredService(); - var claimsPrincipal = CreateClaimsPrincipal(userId); - - using (principalAccessor.Change(claimsPrincipal)) - { - var checker = scope.ServiceProvider.GetRequiredService(); - var param = $"triple-reset-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // Exhaust IP limit - await checker.CheckAsync("TestCompositeTriple", context); - await checker.CheckAsync("TestCompositeTriple", context); - await checker.CheckAsync("TestCompositeTriple", context); - - await Assert.ThrowsAsync(async () => - { - await checker.CheckAsync("TestCompositeTriple", context); - }); - - // Reset - await checker.ResetAsync("TestCompositeTriple", context); - - // All rules should be cleared - var status = await checker.GetStatusAsync("TestCompositeTriple", context); - status.IsAllowed.ShouldBeTrue(); - status.RuleResults![0].RemainingCount.ShouldBe(5); // Parameter: 0/5 - status.RuleResults[1].RemainingCount.ShouldBe(4); // User: 0/4 - status.RuleResults[2].RemainingCount.ShouldBe(3); // IP: 0/3 - - // Should be able to use again - await checker.CheckAsync("TestCompositeTriple", context); - } - } - } - - [Fact] - public async Task Should_Throw_When_PhoneNumber_Not_Available() - { - // No Parameter and no authenticated user - var context = new OperationRateLimitingContext(); - - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestPhoneNumberBased", context); - }); - } - - [Fact] - public async Task Should_Deny_First_Request_When_MaxCount_Is_Zero() - { - var context = new OperationRateLimitingContext { Parameter = $"ban-{Guid.NewGuid()}" }; - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestBanPolicy", context); - }); - - exception.Result.IsAllowed.ShouldBeFalse(); - exception.Result.MaxCount.ShouldBe(0); - exception.Result.RetryAfter.ShouldBeNull(); - exception.HttpStatusCode.ShouldBe(429); - exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently); - } - - [Fact] - public async Task Should_IsAllowed_Return_False_When_MaxCount_Is_Zero() - { - var context = new OperationRateLimitingContext { Parameter = $"ban-allowed-{Guid.NewGuid()}" }; - - var allowed = await _checker.IsAllowedAsync("TestBanPolicy", context); - allowed.ShouldBeFalse(); - } - - [Fact] - public async Task Should_GetStatus_Show_Not_Allowed_When_MaxCount_Is_Zero() - { - var context = new OperationRateLimitingContext { Parameter = $"ban-status-{Guid.NewGuid()}" }; - - var status = await _checker.GetStatusAsync("TestBanPolicy", context); - status.IsAllowed.ShouldBeFalse(); - status.MaxCount.ShouldBe(0); - status.RemainingCount.ShouldBe(0); - status.RetryAfter.ShouldBeNull(); - } - - [Fact] - public async Task Should_Partition_By_Custom_Resolver() - { - // TestCustomResolver uses PartitionBy(ctx => $"action:{ctx.Parameter}") - // Two different parameters => independent counters - var param1 = $"op1-{Guid.NewGuid()}"; - var param2 = $"op2-{Guid.NewGuid()}"; - - var ctx1 = new OperationRateLimitingContext { Parameter = param1 }; - var ctx2 = new OperationRateLimitingContext { Parameter = param2 }; - - // Exhaust param1's quota (max=2) - await _checker.CheckAsync("TestCustomResolver", ctx1); - await _checker.CheckAsync("TestCustomResolver", ctx1); - - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCustomResolver", ctx1); - }); - - // param2 should still be allowed - await _checker.CheckAsync("TestCustomResolver", ctx2); - (await _checker.IsAllowedAsync("TestCustomResolver", ctx2)).ShouldBeTrue(); - } - - [Fact] - public async Task Should_Throw_When_Custom_Resolver_Returns_Null() - { - var context = new OperationRateLimitingContext { Parameter = "test" }; - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCustomResolverNull", context); - }); - - exception.Message.ShouldContain("Custom partition key resolver returned null or empty"); - } - - [Fact] - public void Should_Throw_When_Policy_Has_Duplicate_Rules() - { - var options = new AbpOperationRateLimitingOptions(); - - Assert.Throws(() => - { - options.AddPolicy("DuplicateRulePolicy", policy => - { - policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter()); - policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter()); - }); - }); - } - - - [Fact] - public async Task Should_Return_Correct_CurrentCount_In_RuleResults() - { - var param = $"current-count-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - var status = await _checker.GetStatusAsync("TestSimple", context); - status.RuleResults.ShouldNotBeNull(); - status.RuleResults!.Count.ShouldBe(1); - status.RuleResults[0].CurrentCount.ShouldBe(2); - status.RuleResults[0].RemainingCount.ShouldBe(1); - status.RuleResults[0].MaxCount.ShouldBe(3); - } - - [Fact] - public async Task ResetAsync_Should_Skip_When_Disabled() - { - var options = GetRequiredService>(); - var originalValue = options.Value.IsEnabled; - - try - { - var param = $"reset-disabled-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // Exhaust the quota - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - // Disable and call ResetAsync — should be a no-op (counter not actually reset) - options.Value.IsEnabled = false; - await _checker.ResetAsync("TestSimple", context); - - // Re-enable: quota should still be exhausted because reset was skipped - options.Value.IsEnabled = true; - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestSimple", context); - }); - } - finally - { - options.Value.IsEnabled = originalValue; - } - } - - private static ClaimsPrincipal CreateClaimsPrincipal(Guid userId) - { - return new ClaimsPrincipal( - new ClaimsIdentity( - new[] - { - new Claim(AbpClaimTypes.UserId, userId.ToString()), - new Claim(AbpClaimTypes.Email, "test@example.com"), - new Claim(AbpClaimTypes.PhoneNumber, "1234567890") - }, - "TestAuth")); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs deleted file mode 100644 index 6566bbccdb..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs +++ /dev/null @@ -1,408 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Shouldly; -using Volo.Abp.AspNetCore.ExceptionHandling; -using Volo.Abp.Localization; -using Xunit; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingFrontendIntegration_Tests : OperationRateLimitingTestBase -{ - private readonly IOperationRateLimitingChecker _checker; - private readonly IExceptionToErrorInfoConverter _errorInfoConverter; - private readonly IOperationRateLimitingFormatter _formatter; - - public OperationRateLimitingFrontendIntegration_Tests() - { - _checker = GetRequiredService(); - _errorInfoConverter = GetRequiredService(); - _formatter = GetRequiredService(); - } - - [Fact] - public async Task ErrorInfo_Should_Contain_Localized_Message_En() - { - using (CultureHelper.Use("en")) - { - var param = $"frontend-en-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestSimple", context); - }); - - var errorInfo = _errorInfoConverter.Convert(exception); - - // The localized message should contain "Operation rate limit exceeded" - errorInfo.Message.ShouldContain("Operation rate limit exceeded"); - errorInfo.Message.ShouldContain("minute(s)"); - } - } - - [Fact] - public async Task ErrorInfo_Should_Contain_Localized_Message_ZhHans() - { - using (CultureHelper.Use("zh-Hans")) - { - var param = $"frontend-zh-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestSimple", context); - }); - - var errorInfo = _errorInfoConverter.Convert(exception); - - // The localized message should be in Chinese - errorInfo.Message.ShouldContain("操作频率超出限制"); - errorInfo.Message.ShouldContain("分钟"); - } - } - - [Fact] - public async Task ErrorInfo_Should_Include_Structured_Data_For_Frontend() - { - var param = $"frontend-data-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext - { - Parameter = param, - ExtraProperties = - { - ["Email"] = "user@example.com" - } - }; - - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestSimple", context); - }); - - var errorInfo = _errorInfoConverter.Convert(exception); - - // Frontend receives error.code - errorInfo.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); - - // Frontend receives error.data for countdown timer and UI display - exception.Data["PolicyName"].ShouldBe("TestSimple"); - exception.Data["MaxCount"].ShouldBe(3); - exception.Data["CurrentCount"].ShouldBe(3); - exception.Data["RemainingCount"].ShouldBe(0); - - // RetryAfterSeconds: frontend uses this for countdown - var retryAfterSeconds = (int)exception.Data["RetryAfterSeconds"]!; - retryAfterSeconds.ShouldBeGreaterThan(0); - retryAfterSeconds.ShouldBeLessThanOrEqualTo(3600); // max 1 hour window - - // RetryAfterMinutes: frontend uses this for display - var retryAfterMinutes = (int)exception.Data["RetryAfterMinutes"]!; - retryAfterMinutes.ShouldBeGreaterThan(0); - - // RetryAfter: localized human-readable string - exception.Data["RetryAfter"].ShouldNotBeNull(); - exception.Data["RetryAfter"].ShouldBeOfType(); - - // WindowDurationSeconds: the configured window duration - var windowDurationSeconds = (int)exception.Data["WindowDurationSeconds"]!; - windowDurationSeconds.ShouldBe(3600); // 1 hour window - - // WindowDescription: localized human-readable window description (e.g. "1 hour(s)") - exception.Data["WindowDescription"].ShouldNotBeNull(); - exception.Data["WindowDescription"].ShouldBeOfType(); - - // RuleDetails: complete rule information for frontend - var ruleDetails = exception.Data["RuleDetails"].ShouldBeOfType>>(); - ruleDetails.Count.ShouldBe(1); - ruleDetails[0]["RuleName"].ShouldBe("TestSimple:Rule[3600s,3,Parameter]"); - ruleDetails[0]["MaxCount"].ShouldBe(3); - ruleDetails[0]["IsAllowed"].ShouldBe(false); - ruleDetails[0]["WindowDurationSeconds"].ShouldBe(3600); - ((string)ruleDetails[0]["WindowDescription"]).ShouldNotBeNullOrEmpty(); - ((int)ruleDetails[0]["RetryAfterSeconds"]).ShouldBeGreaterThan(0); - ((string)ruleDetails[0]["RetryAfter"]).ShouldNotBeNullOrEmpty(); - - // ExtraProperties passed through - exception.Data["Email"].ShouldBe("user@example.com"); - } - - [Fact] - public async Task GetStatusAsync_Should_Provide_Countdown_Data_For_Frontend() - { - var param = $"frontend-status-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // Before any requests: frontend can show "3 remaining" - var status = await _checker.GetStatusAsync("TestSimple", context); - status.IsAllowed.ShouldBeTrue(); - status.RemainingCount.ShouldBe(3); - status.MaxCount.ShouldBe(3); - status.CurrentCount.ShouldBe(0); - status.RetryAfter.ShouldBeNull(); - status.WindowDuration.ShouldBe(TimeSpan.FromHours(1)); - - // After 2 requests: frontend shows "1 remaining" - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - status = await _checker.GetStatusAsync("TestSimple", context); - status.IsAllowed.ShouldBeTrue(); - status.RemainingCount.ShouldBe(1); - status.MaxCount.ShouldBe(3); - status.CurrentCount.ShouldBe(2); - - // After exhausting limit: frontend shows countdown - await _checker.CheckAsync("TestSimple", context); - - status = await _checker.GetStatusAsync("TestSimple", context); - status.IsAllowed.ShouldBeFalse(); - status.RemainingCount.ShouldBe(0); - status.CurrentCount.ShouldBe(3); - status.RetryAfter.ShouldNotBeNull(); - status.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); - } - - [Fact] - public async Task Custom_ErrorCode_Should_Appear_In_ErrorInfo() - { - var param = $"frontend-custom-code-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - await _checker.CheckAsync("TestCustomErrorCode", context); - await _checker.CheckAsync("TestCustomErrorCode", context); - - var exception = await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestCustomErrorCode", context); - }); - - var errorInfo = _errorInfoConverter.Convert(exception); - - // Frontend can use error.code to decide which UI to show - errorInfo.Code.ShouldBe("Test:CustomError"); - } - - [Fact] - public void RetryAfterFormatter_Should_Format_Seconds() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromSeconds(30)).ShouldBe("30 second(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromSeconds(30)).ShouldBe("30 秒"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_Minutes() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromMinutes(15)).ShouldBe("15 minute(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromMinutes(15)).ShouldBe("15 分钟"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_MinutesAndSeconds() - { - using (CultureHelper.Use("en")) - { - // 70 seconds = 1 minute and 10 seconds - _formatter.Format(TimeSpan.FromSeconds(70)).ShouldBe("1 minute(s) and 10 second(s)"); - _formatter.Format(TimeSpan.FromSeconds(90)).ShouldBe("1 minute(s) and 30 second(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromSeconds(70)).ShouldBe("1 分钟 10 秒"); - _formatter.Format(TimeSpan.FromSeconds(90)).ShouldBe("1 分钟 30 秒"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_Hours() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromHours(2)).ShouldBe("2 hour(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromHours(2)).ShouldBe("2 小时"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_HoursAndMinutes() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromMinutes(90)).ShouldBe("1 hour(s) and 30 minute(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromMinutes(90)).ShouldBe("1 小时 30 分钟"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_Days() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromDays(1)).ShouldBe("1 day(s)"); - _formatter.Format(TimeSpan.FromDays(3)).ShouldBe("3 day(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromDays(1)).ShouldBe("1 天"); - _formatter.Format(TimeSpan.FromDays(3)).ShouldBe("3 天"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_DaysAndHours() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromHours(30)).ShouldBe("1 day(s) and 6 hour(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromHours(30)).ShouldBe("1 天 6 小时"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_Months() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromDays(30)).ShouldBe("1 month(s)"); - _formatter.Format(TimeSpan.FromDays(90)).ShouldBe("3 month(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromDays(30)).ShouldBe("1 个月"); - _formatter.Format(TimeSpan.FromDays(90)).ShouldBe("3 个月"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_MonthsAndDays() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromDays(45)).ShouldBe("1 month(s) and 15 day(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromDays(45)).ShouldBe("1 个月 15 天"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_Years() - { - using (CultureHelper.Use("en")) - { - _formatter.Format(TimeSpan.FromDays(365)).ShouldBe("1 year(s)"); - _formatter.Format(TimeSpan.FromDays(730)).ShouldBe("2 year(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromDays(365)).ShouldBe("1 年"); - _formatter.Format(TimeSpan.FromDays(730)).ShouldBe("2 年"); - } - } - - [Fact] - public void RetryAfterFormatter_Should_Format_YearsAndMonths() - { - using (CultureHelper.Use("en")) - { - // 1 year + 60 days = 1 year and 2 months - _formatter.Format(TimeSpan.FromDays(425)).ShouldBe("1 year(s) and 2 month(s)"); - } - - using (CultureHelper.Use("zh-Hans")) - { - _formatter.Format(TimeSpan.FromDays(425)).ShouldBe("1 年 2 个月"); - } - } - - [Fact] - public async Task Reset_Should_Allow_Frontend_To_Resume() - { - var param = $"frontend-reset-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // Exhaust limit - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - // Frontend shows "limit reached" - var status = await _checker.GetStatusAsync("TestSimple", context); - status.IsAllowed.ShouldBeFalse(); - - // After reset (e.g. CAPTCHA verified), frontend can resume - await _checker.ResetAsync("TestSimple", context); - - status = await _checker.GetStatusAsync("TestSimple", context); - status.IsAllowed.ShouldBeTrue(); - status.RemainingCount.ShouldBe(3); - status.CurrentCount.ShouldBe(0); - status.RetryAfter.ShouldBeNull(); - } - - [Fact] - public async Task IsAllowedAsync_Can_Be_Used_For_Frontend_PreCheck() - { - var param = $"frontend-precheck-{Guid.NewGuid()}"; - var context = new OperationRateLimitingContext { Parameter = param }; - - // Frontend precheck: button should be enabled - (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); - - // Consume all - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - await _checker.CheckAsync("TestSimple", context); - - // Frontend precheck: button should be disabled - (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); - - // IsAllowedAsync does NOT consume — calling again still returns false, not error - (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs deleted file mode 100644 index b55ffbc966..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Shouldly; -using Volo.Abp.MultiTenancy; -using Xunit; - -namespace Volo.Abp.OperationRateLimiting; - -/// -/// Verifies per-tenant isolation for tenant-scoped partition types and -/// global (cross-tenant) sharing for ClientIp partition type. -/// -public class OperationRateLimitingMultiTenant_Tests : OperationRateLimitingTestBase -{ - private readonly ICurrentTenant _currentTenant; - private readonly IOperationRateLimitingChecker _checker; - - private static readonly Guid TenantA = Guid.NewGuid(); - private static readonly Guid TenantB = Guid.NewGuid(); - - public OperationRateLimitingMultiTenant_Tests() - { - _currentTenant = GetRequiredService(); - _checker = GetRequiredService(); - } - - [Fact] - public async Task Should_Isolate_ByParameter_Between_Tenants() - { - // Same parameter value in different tenants should have independent counters. - var param = $"shared-param-{Guid.NewGuid()}"; - - using (_currentTenant.Change(TenantA)) - { - var ctx = new OperationRateLimitingContext { Parameter = param }; - await _checker.CheckAsync("TestMultiTenantByParameter", ctx); - await _checker.CheckAsync("TestMultiTenantByParameter", ctx); - - // Tenant A exhausted (max=2) - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestMultiTenantByParameter", ctx); - }); - } - - using (_currentTenant.Change(TenantB)) - { - var ctx = new OperationRateLimitingContext { Parameter = param }; - - // Tenant B has its own counter and should still be allowed - await _checker.CheckAsync("TestMultiTenantByParameter", ctx); - (await _checker.IsAllowedAsync("TestMultiTenantByParameter", ctx)).ShouldBeTrue(); - } - } - - [Fact] - public async Task Should_Share_ByClientIp_Across_Tenants() - { - // ClientIp counters are global: requests from the same IP are counted together - // regardless of which tenant context is active. - // The mock IWebClientInfoProvider returns "127.0.0.1" for all requests. - - using (_currentTenant.Change(TenantA)) - { - var ctx = new OperationRateLimitingContext(); - await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); - await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); - } - - using (_currentTenant.Change(TenantB)) - { - var ctx = new OperationRateLimitingContext(); - - // Tenant B shares the same IP counter; should be at limit now - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); - }); - } - } - - [Fact] - public async Task Should_Isolate_ByParameter_Host_Tenant_From_Named_Tenant() - { - // Host context (no tenant) and a specific tenant should have separate counters. - var param = $"host-vs-tenant-{Guid.NewGuid()}"; - - // Host context: exhaust quota - var hostCtx = new OperationRateLimitingContext { Parameter = param }; - await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); - await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); - await Assert.ThrowsAsync(async () => - { - await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); - }); - - // Tenant A should have its own counter, unaffected by host - using (_currentTenant.Change(TenantA)) - { - var tenantCtx = new OperationRateLimitingContext { Parameter = param }; - await _checker.CheckAsync("TestMultiTenantByParameter", tenantCtx); - (await _checker.IsAllowedAsync("TestMultiTenantByParameter", tenantCtx)).ShouldBeTrue(); - } - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs deleted file mode 100644 index ffba340e29..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs +++ /dev/null @@ -1,436 +0,0 @@ -using System; -using System.Threading.Tasks; -using Shouldly; -using Xunit; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingPolicyBuilder_Tests -{ - [Fact] - public void Should_Build_Simple_Policy() - { - var options = new AbpOperationRateLimitingOptions(); - options.AddPolicy("TestPolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter(); - }); - - var policy = options.Policies["TestPolicy"]; - - policy.Name.ShouldBe("TestPolicy"); - policy.Rules.Count.ShouldBe(1); - policy.Rules[0].Duration.ShouldBe(TimeSpan.FromHours(1)); - policy.Rules[0].MaxCount.ShouldBe(5); - policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); - policy.ErrorCode.ShouldBeNull(); - } - - [Fact] - public void Should_Build_Composite_Policy() - { - var options = new AbpOperationRateLimitingOptions(); - options.AddPolicy("CompositePolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromDays(1), maxCount: 10) - .PartitionByCurrentUser()); - }); - - var policy = options.Policies["CompositePolicy"]; - - policy.Name.ShouldBe("CompositePolicy"); - policy.Rules.Count.ShouldBe(2); - policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); - policy.Rules[0].MaxCount.ShouldBe(3); - policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); - policy.Rules[1].MaxCount.ShouldBe(10); - } - - [Fact] - public void Should_Set_ErrorCode() - { - var options = new AbpOperationRateLimitingOptions(); - options.AddPolicy("ErrorPolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) - .PartitionByParameter() - .WithErrorCode("App:Custom:Error"); - }); - - var policy = options.Policies["ErrorPolicy"]; - policy.ErrorCode.ShouldBe("App:Custom:Error"); - } - - [Fact] - public void Should_Build_Custom_Partition() - { - var options = new AbpOperationRateLimitingOptions(); - options.AddPolicy("CustomPolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromMinutes(30), maxCount: 5) - .PartitionBy(ctx => Task.FromResult($"custom:{ctx.Parameter}"))); - }); - - var policy = options.Policies["CustomPolicy"]; - - policy.Rules.Count.ShouldBe(1); - policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); - policy.Rules[0].CustomPartitionKeyResolver.ShouldNotBeNull(); - } - - [Fact] - public void Should_Support_All_Partition_Types() - { - var options = new AbpOperationRateLimitingOptions(); - - options.AddPolicy("P1", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByParameter()); - options.AddPolicy("P2", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByCurrentUser()); - options.AddPolicy("P3", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByCurrentTenant()); - options.AddPolicy("P4", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByClientIp()); - options.AddPolicy("P5", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByEmail()); - options.AddPolicy("P6", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByPhoneNumber()); - - options.Policies["P1"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); - options.Policies["P2"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); - options.Policies["P3"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentTenant); - options.Policies["P4"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.ClientIp); - options.Policies["P5"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Email); - options.Policies["P6"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.PhoneNumber); - } - - [Fact] - public void Should_Throw_When_Policy_Has_No_Rules() - { - var options = new AbpOperationRateLimitingOptions(); - - var exception = Assert.Throws(() => - { - options.AddPolicy("EmptyPolicy", policy => - { - // Intentionally not adding any rules - }); - }); - - exception.Message.ShouldContain("no rules"); - } - - [Fact] - public void Should_Throw_When_WithFixedWindow_Without_PartitionBy() - { - var options = new AbpOperationRateLimitingOptions(); - - var exception = Assert.Throws(() => - { - options.AddPolicy("IncompletePolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5); - // Missing PartitionBy*() call - rule never committed - }); - }); - - exception.Message.ShouldContain("no rules"); - } - - [Fact] - public void Should_Throw_When_AddRule_Without_WithFixedWindow() - { - var options = new AbpOperationRateLimitingOptions(); - - var exception = Assert.Throws(() => - { - options.AddPolicy("NoWindowPolicy", policy => - { - policy.AddRule(rule => - { - // Missing WithFixedWindow call - duration is zero - }); - }); - }); - - exception.Message.ShouldContain("positive duration"); - } - - [Fact] - public void Should_Allow_MaxCount_Zero_For_Ban_Policy() - { - var options = new AbpOperationRateLimitingOptions(); - - // maxCount=0 is a valid "ban" policy - always deny - options.AddPolicy("BanPolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 0) - .PartitionByParameter(); - }); - - var policy = options.Policies["BanPolicy"]; - policy.Rules[0].MaxCount.ShouldBe(0); - } - - [Fact] - public void Should_Throw_When_AddRule_Without_PartitionBy() - { - var options = new AbpOperationRateLimitingOptions(); - - var exception = Assert.Throws(() => - { - options.AddPolicy("NoPartitionPolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5)); - // Missing PartitionBy*() call - }); - }); - - exception.Message.ShouldContain("partition type"); - } - - [Fact] - public void Should_Throw_When_MaxCount_Is_Negative() - { - var options = new AbpOperationRateLimitingOptions(); - - var exception = Assert.Throws(() => - { - options.AddPolicy("NegativePolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: -1) - .PartitionByParameter(); - }); - }); - - exception.Message.ShouldContain("maxCount >= 0"); - } - - [Fact] - public void Should_Allow_Same_Rule_With_Different_MultiTenancy() - { - var options = new AbpOperationRateLimitingOptions(); - - // Same Duration/MaxCount/PartitionType but different IsMultiTenant should be allowed - options.AddPolicy("MultiTenancyPolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .WithMultiTenancy() - .PartitionByParameter()); - }); - - var policy = options.Policies["MultiTenancyPolicy"]; - policy.Rules.Count.ShouldBe(2); - policy.Rules[0].IsMultiTenant.ShouldBeFalse(); - policy.Rules[1].IsMultiTenant.ShouldBeTrue(); - } - - [Fact] - public void Should_Allow_Multiple_Custom_Partition_Rules() - { - var options = new AbpOperationRateLimitingOptions(); - - // Multiple custom partition rules with same Duration/MaxCount should be allowed - // because they may use different key resolvers - options.AddPolicy("MultiCustomPolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionBy(ctx => Task.FromResult($"by-ip:{ctx.Parameter}"))); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionBy(ctx => Task.FromResult($"by-device:{ctx.Parameter}"))); - }); - - var policy = options.Policies["MultiCustomPolicy"]; - policy.Rules.Count.ShouldBe(2); - policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); - policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); - } - - [Fact] - public void AddPolicy_With_Same_Name_Should_Replace_Existing_Policy() - { - var options = new AbpOperationRateLimitingOptions(); - - options.AddPolicy("MyPolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter(); - }); - - // Second AddPolicy with the same name replaces the first one entirely - options.AddPolicy("MyPolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromMinutes(10), maxCount: 2) - .PartitionByCurrentUser(); - }); - - options.Policies.Count.ShouldBe(1); - - var policy = options.Policies["MyPolicy"]; - policy.Rules.Count.ShouldBe(1); - policy.Rules[0].Duration.ShouldBe(TimeSpan.FromMinutes(10)); - policy.Rules[0].MaxCount.ShouldBe(2); - policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); - } - - [Fact] - public void ConfigurePolicy_Should_Override_ErrorCode_While_Keeping_Rules() - { - var options = new AbpOperationRateLimitingOptions(); - - options.AddPolicy("BasePolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter(); - }); - - options.ConfigurePolicy("BasePolicy", policy => - { - policy.WithErrorCode("App:Custom:Override"); - }); - - var result = options.Policies["BasePolicy"]; - result.ErrorCode.ShouldBe("App:Custom:Override"); - result.Rules.Count.ShouldBe(1); - result.Rules[0].MaxCount.ShouldBe(5); - result.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); - } - - [Fact] - public void ConfigurePolicy_Should_Add_Additional_Rule_To_Existing_Policy() - { - var options = new AbpOperationRateLimitingOptions(); - - options.AddPolicy("BasePolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3) - .PartitionByParameter(); - }); - - options.ConfigurePolicy("BasePolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) - .PartitionByClientIp()); - }); - - var result = options.Policies["BasePolicy"]; - result.Rules.Count.ShouldBe(2); - result.Rules[0].Duration.ShouldBe(TimeSpan.FromMinutes(5)); - result.Rules[0].MaxCount.ShouldBe(3); - result.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); - result.Rules[1].Duration.ShouldBe(TimeSpan.FromHours(1)); - result.Rules[1].MaxCount.ShouldBe(20); - result.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.ClientIp); - } - - [Fact] - public void ConfigurePolicy_ClearRules_Should_Replace_All_Rules() - { - var options = new AbpOperationRateLimitingOptions(); - - options.AddPolicy("BasePolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10) - .PartitionByParameter()); - - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromDays(1), maxCount: 50) - .PartitionByCurrentUser()); - }); - - options.ConfigurePolicy("BasePolicy", policy => - { - policy.ClearRules() - .WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3) - .PartitionByEmail(); - }); - - var result = options.Policies["BasePolicy"]; - result.Rules.Count.ShouldBe(1); - result.Rules[0].Duration.ShouldBe(TimeSpan.FromMinutes(5)); - result.Rules[0].MaxCount.ShouldBe(3); - result.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Email); - } - - [Fact] - public void ConfigurePolicy_Should_Support_Chaining() - { - var options = new AbpOperationRateLimitingOptions(); - - options.AddPolicy("PolicyA", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter(); - }); - - options.AddPolicy("PolicyB", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10) - .PartitionByCurrentUser(); - }); - - // ConfigurePolicy returns AbpOperationRateLimitingOptions for chaining - options - .ConfigurePolicy("PolicyA", policy => policy.WithErrorCode("App:LimitA")) - .ConfigurePolicy("PolicyB", policy => policy.WithErrorCode("App:LimitB")); - - options.Policies["PolicyA"].ErrorCode.ShouldBe("App:LimitA"); - options.Policies["PolicyB"].ErrorCode.ShouldBe("App:LimitB"); - } - - [Fact] - public void ConfigurePolicy_Should_Throw_When_Policy_Not_Found() - { - var options = new AbpOperationRateLimitingOptions(); - - var exception = Assert.Throws(() => - { - options.ConfigurePolicy("NonExistentPolicy", policy => - { - policy.WithErrorCode("App:SomeCode"); - }); - }); - - exception.Message.ShouldContain("NonExistentPolicy"); - } - - [Fact] - public void ConfigurePolicy_Should_Preserve_Existing_ErrorCode_When_Not_Overridden() - { - var options = new AbpOperationRateLimitingOptions(); - - options.AddPolicy("BasePolicy", policy => - { - policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionByParameter() - .WithErrorCode("Original:ErrorCode"); - }); - - options.ConfigurePolicy("BasePolicy", policy => - { - policy.AddRule(rule => rule - .WithFixedWindow(TimeSpan.FromMinutes(10), maxCount: 3) - .PartitionByClientIp()); - }); - - var result = options.Policies["BasePolicy"]; - result.ErrorCode.ShouldBe("Original:ErrorCode"); - result.Rules.Count.ShouldBe(2); - result.Rules[0].Duration.ShouldBe(TimeSpan.FromHours(1)); - result.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); - result.Rules[1].Duration.ShouldBe(TimeSpan.FromMinutes(10)); - result.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.ClientIp); - } -} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs deleted file mode 100644 index 4316437e57..0000000000 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Volo.Abp.Testing; - -namespace Volo.Abp.OperationRateLimiting; - -public class OperationRateLimitingTestBase : AbpIntegratedTest -{ - protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) - { - options.UseAutofac(); - } -} diff --git a/nupkg/common.ps1 b/nupkg/common.ps1 index cb356faee4..6fbc34e80c 100644 --- a/nupkg/common.ps1 +++ b/nupkg/common.ps1 @@ -237,7 +237,6 @@ $projects = ( "framework/src/Volo.Abp.Minify", "framework/src/Volo.Abp.ObjectExtending", "framework/src/Volo.Abp.ObjectMapping", - "framework/src/Volo.Abp.OperationRateLimiting", "framework/src/Volo.Abp.Quartz", "framework/src/Volo.Abp.RabbitMQ", "framework/src/Volo.Abp.RemoteServices",