using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.UnitTests.Auth; public sealed class SmsVerificationServiceTests { private const string Pepper = "unit-test-sms-code-pepper-32-characters"; [Fact] public void Hash_uses_the_server_pepper() { var tenantId = Guid.NewGuid(); var first = SmsCodeHashing.Hash(tenantId, "13800000000", SmsPurpose.Login, "123456", Pepper); var second = SmsCodeHashing.Hash( tenantId, "13800000000", SmsPurpose.Login, "123456", "another-unit-test-pepper-32-characters"); Assert.NotEqual(first, second); Assert.Equal(64, first.Length); } [Fact] public async Task Create_code_generates_six_digits_and_consumes_all_available_rate_limit_dimensions() { await using var context = CreateContext(); var tenantId = await SeedTenantAsync(context); var provider = new CapturingSmsProvider(); var service = CreateService(context, provider); await service.CreateCodeAsync(new SendSmsCodeRequest( tenantId, "13800000000", SmsPurpose.Login, "127.0.0.1", "test-device")); Assert.Matches("^[0-9]{6}$", Assert.Single(provider.Requests).Code); Assert.Equal(4, context.SmsSendRateLimits.Count()); Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Tenant); Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Phone); Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Ip); Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Device); var sendEvent = Assert.Single(context.AuthLoginEvents); Assert.Equal(AuthLoginResult.Sent, sendEvent.Result); Assert.Equal("sms", sendEvent.Provider); } [Fact] public async Task Fifth_invalid_attempt_blocks_code_and_correct_code_is_then_rejected() { await using var context = CreateContext(); var tenantId = await SeedTenantAsync(context); var verification = await SeedCodeAsync(context, tenantId, "123456"); var service = CreateService(context); for (var attempt = 0; attempt < 5; attempt++) { await Assert.ThrowsAsync(() => service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "999999")); } context.ChangeTracker.Clear(); var blocked = await context.SmsVerificationCodes.FindAsync(verification.Id); Assert.NotNull(blocked); Assert.Equal(5, blocked.Attempts); Assert.Equal(SmsVerificationStatus.Blocked, blocked.Status); await Assert.ThrowsAsync(() => service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456")); } [Fact] public async Task Expired_code_is_transitioned_to_expired() { await using var context = CreateContext(); var tenantId = await SeedTenantAsync(context); var verification = await SeedCodeAsync( context, tenantId, "123456", DateTimeOffset.UtcNow.AddSeconds(-1)); var service = CreateService(context); await Assert.ThrowsAsync(() => service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456")); context.ChangeTracker.Clear(); Assert.Equal( SmsVerificationStatus.Expired, (await context.SmsVerificationCodes.FindAsync(verification.Id))!.Status); } [Fact] public async Task Successful_code_can_only_be_consumed_once() { await using var context = CreateContext(); var tenantId = await SeedTenantAsync(context); var verification = await SeedCodeAsync(context, tenantId, "123456"); var service = CreateService(context); await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"); await Assert.ThrowsAsync(() => service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456")); context.ChangeTracker.Clear(); var consumed = await context.SmsVerificationCodes.FindAsync(verification.Id); Assert.Equal(SmsVerificationStatus.Verified, consumed!.Status); Assert.NotNull(consumed.ConsumedAt); } [Fact] public async Task Phone_limit_applies_across_ip_and_device_changes() { await using var context = CreateContext(); var tenantId = await SeedTenantAsync(context); var options = new SmsSecurityOptions { CodePepper = Pepper, TenantRequestsPerHour = 100, PhoneRequestsPerHour = 1, IpRequestsPerHour = 20, DeviceRequestsPerHour = 10 }; var service = CreateService(context, options: options); await service.CreateCodeAsync(new SendSmsCodeRequest( tenantId, "13800000000", SmsPurpose.Login, "127.0.0.1", "device-one")); await Assert.ThrowsAsync(() => service.CreateCodeAsync(new SendSmsCodeRequest( tenantId, "13800000000", SmsPurpose.Login, "127.0.0.2", "device-two"))); } [Fact] public async Task Successful_sms_consumes_quota_once_without_compensation() { await using var context = CreateContext(); var tenantId = await SeedTenantAsync(context); var quota = new RecordingFeatureAccessService(); var service = CreateService(context, featureAccessService: quota); await service.CreateCodeAsync(new SendSmsCodeRequest( tenantId, "13800000000", SmsPurpose.Login, "127.0.0.1", "quota-success")); Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions); Assert.Empty(quota.Releases); } [Fact] public async Task Failed_sms_send_releases_reserved_quota() { await using var context = CreateContext(); var tenantId = await SeedTenantAsync(context); var quota = new RecordingFeatureAccessService(); var service = CreateService(context, new FailingSmsProvider(), featureAccessService: quota); await Assert.ThrowsAsync(() => service.CreateCodeAsync(new SendSmsCodeRequest( tenantId, "13800000000", SmsPurpose.Login, "127.0.0.1", "quota-failure"))); Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions); Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Releases); Assert.Equal(SmsVerificationStatus.Failed, Assert.Single(context.SmsVerificationCodes).Status); } [Fact] public async Task Exhausted_sms_quota_rejects_before_provider_send() { await using var context = CreateContext(); var tenantId = await SeedTenantAsync(context); var provider = new CapturingSmsProvider(); var quota = new RecordingFeatureAccessService { AllowConsumption = false }; var service = CreateService(context, provider, featureAccessService: quota); var exception = await Assert.ThrowsAsync(() => service.CreateCodeAsync(new SendSmsCodeRequest( tenantId, "13800000000", SmsPurpose.Login, "127.0.0.1", "quota-exhausted"))); Assert.Equal("feature_quota_exhausted", exception.Code); Assert.Empty(provider.Requests); Assert.Empty(quota.Releases); } private static SmsVerificationService CreateService( TikuDbContext context, ISmsProvider? provider = null, SmsSecurityOptions? options = null, IFeatureAccessService? featureAccessService = null) { return new SmsVerificationService( context, provider ?? new CapturingSmsProvider(), featureAccessService ?? new RecordingFeatureAccessService(), Options.Create(options ?? ValidOptions())); } private static SmsSecurityOptions ValidOptions() { return new SmsSecurityOptions { CodePepper = Pepper, TenantRequestsPerHour = 100, PhoneRequestsPerHour = 5, IpRequestsPerHour = 20, DeviceRequestsPerHour = 10 }; } private static TikuDbContext CreateContext() { return new TikuDbContext( new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); } private static async Task SeedTenantAsync(TikuDbContext context) { var tenant = new Tenant { Id = Guid.NewGuid(), Slug = Guid.NewGuid().ToString("N"), Name = "SMS Test Tenant" }; context.Tenants.Add(tenant); await context.SaveChangesAsync(); return tenant.Id; } private static async Task SeedCodeAsync( TikuDbContext context, Guid tenantId, string code, DateTimeOffset? expiresAt = null) { var verification = new SmsVerificationCode { TenantId = tenantId, Phone = "13800000000", Purpose = SmsPurpose.Login, CodeHash = SmsCodeHashing.Hash( tenantId, "13800000000", SmsPurpose.Login, code, Pepper), Status = SmsVerificationStatus.Sent, ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(5) }; context.SmsVerificationCodes.Add(verification); await context.SaveChangesAsync(); return verification; } private sealed class CapturingSmsProvider : ISmsProvider { public List Requests { get; } = []; public Task SendAsync( SmsProviderSendRequest request, CancellationToken cancellationToken = default) { Requests.Add(request); return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id")); } } private sealed class FailingSmsProvider : ISmsProvider { public Task SendAsync( SmsProviderSendRequest request, CancellationToken cancellationToken = default) { throw new SmsProviderException("provider failed", "sms_provider_send_failed"); } } private sealed class RecordingFeatureAccessService : IFeatureAccessService { public bool AllowConsumption { get; init; } = true; public List<(Guid TenantId, string MetricCode, long Amount)> Consumptions { get; } = []; public List<(Guid TenantId, string MetricCode, long Amount)> Releases { get; } = []; public Task TryConsumeQuotaAsync( Guid tenantId, string metricCode, long amount, CancellationToken cancellationToken = default) { Consumptions.Add((tenantId, metricCode, amount)); return Task.FromResult(AllowConsumption); } public Task ReleaseQuotaAsync( Guid tenantId, string metricCode, long amount, CancellationToken cancellationToken = default) { Releases.Add((tenantId, metricCode, amount)); return Task.CompletedTask; } public Task EvaluateAsync(Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetEnabledFeaturesAsync(Guid tenantId, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> FilterPermissionCodesAsync(Guid tenantId, IEnumerable permissionCodes, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetQuotaSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }