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(); } }