mirror of https://github.com/abpframework/abp.git
csharpabpc-sharpframeworkblazoraspnet-coredotnet-coreaspnetcorearchitecturesaasdomain-driven-designangularmulti-tenancy
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
798 lines
30 KiB
798 lines
30 KiB
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<IOperationRateLimitingChecker>();
|
|
}
|
|
|
|
[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<AbpOperationRateLimitingException>(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<AbpOperationRateLimitingException>(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<ICurrentPrincipalAccessor>();
|
|
var claimsPrincipal = CreateClaimsPrincipal(userId);
|
|
|
|
using (principalAccessor.Change(claimsPrincipal))
|
|
{
|
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>();
|
|
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<ICurrentPrincipalAccessor>();
|
|
var claimsPrincipal = CreateClaimsPrincipal(userId);
|
|
|
|
using (principalAccessor.Change(claimsPrincipal))
|
|
{
|
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>();
|
|
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<AbpOperationRateLimitingException>(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<AbpOperationRateLimitingException>(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<AbpOperationRateLimitingException>(async () =>
|
|
{
|
|
await _checker.CheckAsync("TestCustomErrorCode", context);
|
|
});
|
|
|
|
exception.Code.ShouldBe("Test:CustomError");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Throw_For_Unknown_Policy()
|
|
{
|
|
await Assert.ThrowsAsync<AbpException>(async () =>
|
|
{
|
|
await _checker.CheckAsync("NonExistentPolicy");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_Skip_When_Disabled()
|
|
{
|
|
var options = GetRequiredService<Microsoft.Extensions.Options.IOptions<AbpOperationRateLimitingOptions>>();
|
|
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<AbpOperationRateLimitingException>(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<AbpOperationRateLimitingException>(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<ICurrentPrincipalAccessor>();
|
|
var claimsPrincipal = CreateClaimsPrincipal(userId);
|
|
|
|
using (principalAccessor.Change(claimsPrincipal))
|
|
{
|
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>();
|
|
|
|
// 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<AbpOperationRateLimitingException>(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<AbpOperationRateLimitingException>(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<ICurrentPrincipalAccessor>();
|
|
var claimsPrincipal = CreateClaimsPrincipal(userId);
|
|
|
|
using (principalAccessor.Change(claimsPrincipal))
|
|
{
|
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>();
|
|
|
|
// 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<AbpOperationRateLimitingException>(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<AbpException>(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<ICurrentPrincipalAccessor>();
|
|
var claimsPrincipal = CreateClaimsPrincipal(userId);
|
|
|
|
using (principalAccessor.Change(claimsPrincipal))
|
|
{
|
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>();
|
|
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<AbpOperationRateLimitingException>(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<AbpOperationRateLimitingException>(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<AbpOperationRateLimitingException>(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<ICurrentPrincipalAccessor>();
|
|
var claimsPrincipal = CreateClaimsPrincipal(userId);
|
|
|
|
using (principalAccessor.Change(claimsPrincipal))
|
|
{
|
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>();
|
|
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<AbpOperationRateLimitingException>(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<ICurrentPrincipalAccessor>();
|
|
var claimsPrincipal = CreateClaimsPrincipal(userId);
|
|
|
|
using (principalAccessor.Change(claimsPrincipal))
|
|
{
|
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>();
|
|
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<AbpOperationRateLimitingException>(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<ICurrentPrincipalAccessor>();
|
|
var claimsPrincipal = CreateClaimsPrincipal(userId);
|
|
|
|
using (principalAccessor.Change(claimsPrincipal))
|
|
{
|
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>();
|
|
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<AbpOperationRateLimitingException>(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<AbpException>(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<AbpOperationRateLimitingException>(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<AbpOperationRateLimitingException>(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<AbpException>(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<AbpException>(() =>
|
|
{
|
|
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<Microsoft.Extensions.Options.IOptions<AbpOperationRateLimitingOptions>>();
|
|
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<AbpOperationRateLimitingException>(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"));
|
|
}
|
|
}
|
|
|