using System.ComponentModel.DataAnnotations; using System.Net; using System.Reflection; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Configuration; using Tiku.Api.Controllers; using Tiku.Api.Middleware; using Tiku.Api.Options; namespace Tiku.IntegrationTests.Api; public sealed class AuthRateLimitPolicyTests { [Fact] public void Authentication_rate_limits_bind_from_the_named_configuration_section() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { [$"{AuthRateLimitOptions.SectionName}:PasswordPermitLimit"] = "7", [$"{AuthRateLimitOptions.SectionName}:PasswordWindowSeconds"] = "600", [$"{AuthRateLimitOptions.SectionName}:SmsPermitLimit"] = "3", [$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90" }) .Build(); var options = configuration .GetSection(AuthRateLimitOptions.SectionName) .Get(); Assert.NotNull(options); Assert.Equal(7, options.PasswordPermitLimit); Assert.Equal(600, options.PasswordWindowSeconds); Assert.Equal(3, options.SmsPermitLimit); Assert.Equal(90, options.SmsWindowSeconds); } [Fact] public void Authentication_rate_limit_values_must_be_positive() { var options = new AuthRateLimitOptions { PasswordPermitLimit = 0, SmsWindowSeconds = 0 }; var validationResults = new List(); var valid = Validator.TryValidateObject( options, new ValidationContext(options), validationResults, true); Assert.False(valid); Assert.Equal(2, validationResults.Count); } [Fact] public void Password_login_uses_the_password_named_policy() { AssertPolicy(nameof(AuthController.LoginWithPassword), AuthRateLimitPolicies.Password); } [Fact] public void Sms_send_uses_the_sms_named_policy() { AssertPolicy(nameof(AuthController.SendSmsCode), AuthRateLimitPolicies.Sms); } [Fact] public void Required_password_change_uses_the_password_named_policy() { AssertPolicy(nameof(AuthController.ChangeRequiredPassword), AuthRateLimitPolicies.Password); } [Fact] public async Task Password_partition_combines_account_and_ip_without_exposing_the_account() { var first = await CapturePartitionAsync( AuthRateLimitPolicies.Password, """{"Phone":"13800000000","password":"secret"}""", "127.0.0.1"); var same = await CapturePartitionAsync( AuthRateLimitPolicies.Password, """{"phone":"13800000000","password":"different"}""", "127.0.0.1"); var differentAccount = await CapturePartitionAsync( AuthRateLimitPolicies.Password, """{"phone":"13900000000","password":"secret"}""", "127.0.0.1"); var differentIp = await CapturePartitionAsync( AuthRateLimitPolicies.Password, """{"phone":"13800000000","password":"secret"}""", "127.0.0.2"); Assert.Equal(first, same); Assert.NotEqual(first, differentAccount); Assert.NotEqual(first, differentIp); Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal); Assert.DoesNotContain("secret", first, StringComparison.Ordinal); } [Fact] public async Task Sms_partition_combines_phone_and_ip_without_exposing_the_phone() { var first = await CapturePartitionAsync( AuthRateLimitPolicies.Sms, """{"phone":"13800000000","deviceId":"device-one"}""", "127.0.0.1"); var differentPhone = await CapturePartitionAsync( AuthRateLimitPolicies.Sms, """{"phone":"13900000000","deviceId":"device-one"}""", "127.0.0.1"); var differentIp = await CapturePartitionAsync( AuthRateLimitPolicies.Sms, """{"phone":"13800000000","deviceId":"device-one"}""", "127.0.0.2"); Assert.NotEqual(first, differentPhone); Assert.NotEqual(first, differentIp); Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal); } private static void AssertPolicy(string methodName, string expectedPolicy) { var method = typeof(AuthController).GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance); var attribute = method?.GetCustomAttribute(); Assert.NotNull(attribute); Assert.Equal(expectedPolicy, attribute.PolicyName); } private static async Task CapturePartitionAsync( string policyName, string json, string ipAddress) { var context = new DefaultHttpContext(); context.Connection.RemoteIpAddress = IPAddress.Parse(ipAddress); context.Request.Method = HttpMethods.Post; context.Request.ContentType = "application/json"; context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); context.SetEndpoint(new Endpoint( _ => Task.CompletedTask, new EndpointMetadataCollection(new EnableRateLimitingAttribute(policyName)), "auth-rate-limit-test")); string? partition = null; var middleware = new AuthRateLimitPartitionMiddleware(async nextContext => { partition = AuthRateLimitPartitionKey.Resolve(nextContext, policyName); using var reader = new StreamReader( nextContext.Request.Body, Encoding.UTF8, leaveOpen: true); Assert.Equal(json, await reader.ReadToEndAsync()); }); await middleware.InvokeAsync(context); return Assert.IsType(partition); } }