94 lines
3.6 KiB
C#
94 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);
|
|
}
|
|
} |