Files
tiku-backend.net/Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs

95 lines
3.6 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class SmsVerificationConcurrencyTests
{
[Fact]
public async Task Concurrent_verification_consumes_a_code_exactly_once()
{
await using var factory = new ApiTestFactory();
var seed = await SeedCodeAsync(factory, "123456");
var results = await Task.WhenAll(
TryVerifyAsync(factory, seed.TenantId, "123456"),
TryVerifyAsync(factory, seed.TenantId, "123456"));
Assert.Single(results, succeeded => succeeded);
using var scope = factory.CreateSystemScope("Verify concurrent SMS consumption");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var verification = await dbContext.SmsVerificationCodes.SingleAsync(item => item.Id == seed.CodeId);
Assert.Equal(SmsVerificationStatus.Verified, verification.Status);
Assert.NotNull(verification.ConsumedAt);
}
[Fact]
public async Task Concurrent_invalid_attempts_atomically_block_on_the_fifth_failure()
{
await using var factory = new ApiTestFactory();
var seed = await SeedCodeAsync(factory, "123456");
var results = await Task.WhenAll(Enumerable.Range(0, 5)
.Select(_ => TryVerifyAsync(factory, seed.TenantId, "999999")));
Assert.DoesNotContain(true, results);
using var scope = factory.CreateSystemScope("Verify concurrent SMS blocking");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var verification = await dbContext.SmsVerificationCodes.SingleAsync(item => item.Id == seed.CodeId);
Assert.Equal(5, verification.Attempts);
Assert.Equal(SmsVerificationStatus.Blocked, verification.Status);
}
private static async Task<bool> TryVerifyAsync(ApiTestFactory factory, Guid tenantId, string code)
{
using var scope = factory.CreateSystemScope("Concurrent SMS verification");
var service = scope.ServiceProvider.GetRequiredService<ISmsVerificationService>();
try
{
await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, code);
return true;
}
catch (InvalidCredentialsException)
{
return false;
}
}
private static async Task<(Guid TenantId, Guid CodeId)> SeedCodeAsync(
ApiTestFactory factory,
string code)
{
using var scope = factory.CreateSystemScope("Read SMS test options");
var options = scope.ServiceProvider.GetRequiredService<IOptions<SmsSecurityOptions>>().Value;
var tenantId = Guid.NewGuid();
var verification = new SmsVerificationCode
{
TenantId = tenantId,
Phone = "13800000000",
Purpose = SmsPurpose.Login,
CodeHash = SmsCodeHashing.Hash(
tenantId,
"13800000000",
SmsPurpose.Login,
code,
options.CodePepper),
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
};
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Concurrent SMS Tenant"
},
verification);
return (tenantId, verification.Id);
}
}